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::lint_context::column_at;
5use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
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::calculate_excess_range;
11use crate::utils::regex_cache::{IMAGE_REF_PATTERN, LINK_REF_PATTERN, URL_PATTERN};
12use crate::utils::table_utils::TableUtils;
13use crate::utils::text_reflow::{
14    BlockquoteLineData, blockquote_continuation_style, code_span_crosses_last_break, code_span_runs_into_indentation,
15    dominant_blockquote_prefix, is_self_contained_display_math_line, join_sentence_lines, join_soft_break_lines,
16    reflow_blockquote_content, split_into_sentences, trim_breakable_whitespace,
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::html_block::opens_tag_line_html_block;
26use crate::utils::is_template_directive_only;
27use crate::utils::list_indent_shift::is_lazy_continuation;
28use block_builder::{Block, BlockBuilder};
29use helpers::{
30    ends_in_bare_list_marker, extract_list_marker_and_content, has_hard_break, holds_only_container_markers,
31    is_github_alert_marker, is_horizontal_rule, is_html_only_line, is_list_item, is_setext_heading_text_line,
32    is_setext_underline_content, is_standalone_link_or_image_line, is_unwrappable_line, item_owns_lines_from,
33    marker_line_opens_non_paragraph, markers_padded_with_tab, may_be_link_ref_def, source_list_marker,
34    split_into_segments, standalone_link_ends_paragraph, trim_preserving_hard_break,
35};
36pub use md013_config::MD013Config;
37use md013_config::{CjkSoftBreak, LengthMode, ReflowMode};
38
39#[cfg(test)]
40mod tests;
41use unicode_width::UnicodeWidthStr;
42
43#[derive(Clone, Default)]
44pub struct MD013LineLength {
45    pub(crate) config: MD013Config,
46    /// MD030 list-marker spacing, applied when reflowing list items so the rewrite
47    /// uses the configured post-marker spacing rather than a hard-coded single
48    /// space. Defaults to MD030's defaults (a single space everywhere), which
49    /// reproduces the previous behaviour exactly. See [`MD030Config::expected_spaces`].
50    pub(crate) list_spacing: MD030Config,
51}
52
53/// Blockquote paragraph line collected for reflow, with original line index for range computation.
54struct CollectedBlockquoteLine {
55    line_idx: usize,
56    data: BlockquoteLineData,
57}
58
59/// A deliberately conservative MDG step candidate.
60///
61/// Gherkin keywords are localized, so recognizing the word after the marker is
62/// not reliable without the complete dialect table. MDG represents steps as
63/// unordered Markdown list items; withholding reflow from all such items in the
64/// flavor is safer than splitting a step and changing its Gherkin text.
65fn is_potential_mdg_step(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
66    let Some(item) = ctx.list_item_on_line(line_num) else {
67        return false;
68    };
69    !item.is_ordered() && matches!(item.marker_char(), Some('*' | '-' | '+'))
70}
71
72/// One flag per line of the document: whether a GFM reader takes the line for
73/// part of a table, the header line, the delimiter row under it, or a body
74/// row after that, up to a blank line or the next block.
75///
76/// GFM tables need no pipes (`head` over `---:` is a one-cell table), and
77/// rumdl's own table detection, which feeds the table rules, wants pipes and
78/// leaves such tables to the paragraph collectors. This reads the delimiter
79/// row by the GFM grammar instead, in whatever container the line sits:
80/// a delimiter row holds no `>`, so stripping every leading `>` and space
81/// leaves the row itself.
82fn gfm_table_lines(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
83    let lines = &ctx.lines;
84    let text = |idx: usize| lines[idx].content(ctx.content).trim_start_matches([' ', '\t', '>']);
85    // The `>` markers and the width of the whole run of markers and
86    // whitespace a line starts with.
87    let prefix = |idx: usize| {
88        let content = lines[idx].content(ctx.content);
89        let lead = &content[..content.len() - text(idx).len()];
90        (lead.matches('>').count(), lead.len())
91    };
92    let opaque = |idx: usize| {
93        let line = &lines[idx];
94        line.in_code_block || line.in_html_block || line.in_front_matter || line.in_math_block
95    };
96    let mut flags = vec![false; lines.len()];
97    for idx in 1..lines.len() {
98        let header = idx - 1;
99        if flags[header]
100            || opaque(header)
101            || lines[header].heading.is_some()
102            || text(header).is_empty()
103            || !crate::utils::text_reflow::is_table_delimiter_row(text(idx))
104        {
105            continue;
106        }
107        flags[header] = true;
108        flags[idx] = true;
109        // A body row sits in the delimiter row's container: a line with
110        // another number of `>` markers, or short of the delimiter row's
111        // column, is a lazy line, which only a paragraph takes in.
112        let (quotes, column) = prefix(idx);
113        for (body, flag) in flags.iter_mut().enumerate().skip(idx + 1) {
114            let (body_quotes, body_column) = prefix(body);
115            if opaque(body)
116                || lines[body].list_item.is_some()
117                || lines[body].heading.is_some()
118                || text(body).is_empty()
119                || body_quotes != quotes
120                || body_column < column
121            {
122                break;
123            }
124            *flag = true;
125        }
126    }
127    flags
128}
129
130/// Whether line `line_num` (1-based) is touched on either boundary by a code
131/// span crossing more than one line: a span containing the newline that ends
132/// the line before it, or the newline that ends it.
133///
134/// A renderer reads the line break inside a code span as one space, so such a
135/// line is code however it is spelled, and a `$$...$$` expression on it is no
136/// display block. A span that begins and ends on the line itself does not
137/// matter. `flags` holds one entry per line of the document, indexed here by
138/// the 1-based line number, and comes from
139/// [`crate::utils::text_reflow::lines_touching_multiline_code_span`], which
140/// reads code spans on their own rather than alongside math delimiters: a
141/// `$$...$$` pair closes wherever the next `$$` sits, backtick or not, so a
142/// backtick between them that in fact opens a span reaching past the line
143/// needs a parser that isn't also deciding where the math closes.
144fn line_touches_multiline_code_span(flags: &[bool], line_num: usize) -> bool {
145    line_num
146        .checked_sub(1)
147        .and_then(|idx| flags.get(idx))
148        .copied()
149        .unwrap_or(false)
150}
151
152/// How many bytes of indentation to strip from the 0-indexed continuation
153/// line `idx` of a container whose content starts at `content_col`, given
154/// that the line is indented `indent`.
155///
156/// Outside a code span the whole indentation renders as nothing. A code span
157/// crossing into the line keeps every character past the container's content
158/// column, so only the container's own indentation goes.
159fn continuation_indent_to_strip(
160    ctx: &crate::lint_context::LintContext,
161    idx: usize,
162    indent: usize,
163    content_col: usize,
164) -> usize {
165    if ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset) {
166        indent.min(content_col)
167    } else {
168        indent
169    }
170}
171
172/// The content column of the deepest list item enclosing the item on the
173/// 0-indexed line `item_idx` that a line indented `indent` still reaches, or 0
174/// when it reaches none.
175///
176/// A lazy continuation line falls short of the innermost item, but it matches
177/// every enclosing item out to that one, so their indentation is container
178/// indentation and never part of a code span crossing into the line.
179fn enclosing_item_content_col_reached(ctx: &crate::lint_context::LintContext, item_idx: usize, indent: usize) -> usize {
180    let mut limit = ctx.lines[item_idx]
181        .list_item
182        .as_ref()
183        .map_or(0, |item| item.marker_column);
184    for j in (0..item_idx).rev() {
185        if limit == 0 {
186            break;
187        }
188        let info = &ctx.lines[j];
189        if let Some(item) = &info.list_item {
190            if item.marker_column < limit {
191                // Enclosing items are met innermost first, and every one
192                // further out has a smaller content column, so the first one
193                // reached is the deepest.
194                if item.content_column <= indent {
195                    return item.content_column;
196                }
197                limit = item.marker_column;
198            }
199        } else if !info.is_blank && info.indent < limit && !is_lazy_continuation(ctx, j) {
200            // A line short of the marker column that continues no paragraph
201            // ended every item that could have enclosed this one.
202            break;
203        }
204    }
205    0
206}
207
208/// `trimmed`, the text of the 0-indexed line `idx` with its end trimmed, with
209/// that end restored when a code span crosses the line break after it: the
210/// span keeps every character before the break, so the whitespace there is
211/// code, and no hard break.
212fn restore_code_span_line_end(ctx: &crate::lint_context::LintContext, idx: usize, trimmed: String) -> String {
213    if !line_ends_in_code_span(ctx, idx) {
214        return trimmed;
215    }
216    let raw = source_line_without_cr(ctx, idx);
217    let ending = &raw[raw.trim_end_matches([' ', '\t']).len()..];
218    format!("{}{ending}", trimmed.trim_end_matches([' ', '\t']))
219}
220
221/// The text of the 0-indexed blockquote line `idx` after its `>` markers, as a
222/// block of the quote whose content starts `content_col` bytes into the quote's
223/// content holds it: trimmed at both ends, except where a code span crosses
224/// the line break at either end.
225///
226/// The parsed `content` starts after all of the whitespace following the last
227/// `>`, but the quote itself takes only the one optional space after it, and
228/// the block inside it only up to its content column. A code span crossing
229/// into the line keeps the rest.
230fn blockquote_continuation_line(
231    ctx: &crate::lint_context::LintContext,
232    idx: usize,
233    bq: &crate::lint_context::types::BlockquoteInfo,
234    content_col: usize,
235) -> String {
236    let mut text = trim_preserving_hard_break(&bq.content);
237    if ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset) {
238        let indent = &bq.prefix[blockquote_content_start(&bq.prefix)..];
239        text.insert_str(0, &indent[content_col.min(indent.len())..]);
240    }
241    restore_code_span_line_end(ctx, idx, text)
242}
243
244/// Whether the 0-indexed line `idx` opens a list item whose marker is the last
245/// thing on the line. The item's content column is one past the marker, so the
246/// line below it is the item's content only when indented that far; reflow
247/// leaves such a marker line alone and never lets a paragraph run through it.
248/// The parser decides that the marker opens an item (a lone `1.` that cannot
249/// interrupt a paragraph is text); the text decides that nothing follows it.
250fn opens_bare_item(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
251    ctx.lines.get(idx).is_some_and(|line| line.list_item.is_some())
252        && ends_in_bare_list_marker(ctx.lines[idx].content(ctx.content))
253}
254
255/// Where the quote's own content starts in a blockquote `prefix`: after the
256/// last `>` and the one optional space following it.
257fn blockquote_content_start(prefix: &str) -> usize {
258    let after_marker = prefix.rfind('>').map_or(0, |pos| pos + 1);
259    after_marker + usize::from(prefix[after_marker..].starts_with(' '))
260}
261
262/// Whether a code span crosses the line break ending the 0-indexed line `idx`.
263fn line_ends_in_code_span(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
264    ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset + source_line_without_cr(ctx, idx).len())
265}
266
267fn source_line_without_cr<'a>(ctx: &'a crate::lint_context::LintContext, idx: usize) -> &'a str {
268    let raw = ctx.lines[idx].content(ctx.content);
269    raw.strip_suffix('\r').unwrap_or(raw)
270}
271
272/// Whether any of the lines `start_idx..=end_idx` (0-indexed) is a definition
273/// list's term, a definition's marker line holding its text, or a line of a
274/// definition the parser opens on a colon touching its text.
275///
276/// The container reflow paths join these into prose: a term is absorbed into
277/// the text around it, and a marker loses the spacing that sets the
278/// definition's content column. A definition's later paragraph is prose at
279/// the definition's indentation, which the paths keep, and a list or
280/// blockquote nested inside a definition holds none of these lines, so both
281/// still reflow. The lines of a definition opened on a colon touching its
282/// text are prose holding inline constructs the context does not report, so
283/// they are kept as written too.
284fn holds_definition_list(ctx: &crate::lint_context::LintContext, start_idx: usize, end_idx: usize) -> bool {
285    (start_idx..=end_idx).any(|idx| {
286        let line_num = idx + 1;
287        ctx.is_definition_term(line_num)
288            || ctx.in_colon_definition(line_num)
289            || ctx
290                .definition_text_at(line_num)
291                .is_some_and(|text| text.start_line == line_num && text.marker_prefix_len.is_some())
292    })
293}
294
295/// The span holding `line` among `spans`, which are ordered and do not overlap.
296fn span_containing(spans: &[std::ops::RangeInclusive<usize>], line: usize) -> Option<&std::ops::RangeInclusive<usize>> {
297    let index = spans.partition_point(|span| *span.end() < line);
298    spans.get(index).filter(|span| *span.start() <= line)
299}
300
301impl MD013LineLength {
302    pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
303        Self {
304            config: MD013Config {
305                line_length: crate::types::LineLength::new(line_length),
306                code_blocks,
307                code_spans: true,
308                tables,
309                headings,
310                math_blocks: true,
311                bracket_display_math: false,
312                paragraphs: true,  // Default to true for backwards compatibility
313                blockquotes: true, // Default to true for backwards compatibility
314                strict,
315                stern: false,
316                heading_line_length: None,
317                code_block_line_length: None,
318                reflow: false,
319                reflow_mode: ReflowMode::default(),
320                length_mode: LengthMode::default(),
321                abbreviations: Vec::new(),
322                require_sentence_capital: true,
323                ignore_link_urls: true,
324                atomic_spans: true,
325                reflow_break_link_text: false,
326                reflow_break_code_spans: true,
327                reflow_length_exemptions: false,
328                cjk_soft_break: CjkSoftBreak::default(),
329            },
330            list_spacing: MD030Config::default(),
331        }
332    }
333
334    pub fn from_config_struct(config: MD013Config) -> Self {
335        Self {
336            config,
337            list_spacing: MD030Config::default(),
338        }
339    }
340
341    /// Return a clone with code block checking disabled.
342    /// Used for doc comment linting where code blocks are Rust code managed by rustfmt.
343    pub fn with_code_blocks_disabled(&self) -> Self {
344        let mut clone = self.clone();
345        clone.config.code_blocks = false;
346        clone
347    }
348
349    /// Convert MD013 LengthMode to text_reflow ReflowLengthMode
350    /// Normalized set of reference labels defined in the document.
351    ///
352    /// Passed to reflow so a bare shortcut reference (`[text]`) is treated as an
353    /// atomic link only when its label is actually defined; an undefined
354    /// bracketed run reflows as literal prose.
355    fn defined_reference_labels(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<String> {
356        ctx.reference_definitions()
357            .iter()
358            .map(|d| crate::utils::text_reflow::normalize_reference_label(&d.id))
359            .collect()
360    }
361
362    /// Build the reflow options shared by every MD013 fix path.
363    ///
364    /// `line_length` varies per call site (some subtract a list-marker or
365    /// blockquote prefix); every other field is derived uniformly from the
366    /// effective config and the document flavor. Callers that need a different
367    /// `max_list_continuation_indent` override it via struct update.
368    fn reflow_options(
369        ctx: &crate::lint_context::LintContext,
370        config: &MD013Config,
371        line_length: usize,
372    ) -> crate::utils::text_reflow::ReflowOptions {
373        crate::utils::text_reflow::ReflowOptions {
374            line_length,
375            break_on_sentences: true,
376            preserve_breaks: false,
377            sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
378            sentence_pack: config.reflow_mode == ReflowMode::SentencePack,
379            first_line_length: None,
380            semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
381            abbreviations: config.abbreviations_for_reflow(),
382            length_mode: config.reflow_length_mode(),
383            attr_lists: ctx.flavor.supports_attr_lists(),
384            myst_roles: ctx.flavor.supports_myst_roles(),
385            require_sentence_capital: config.require_sentence_capital,
386            max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
387                Some(4)
388            } else {
389                None
390            },
391            defined_references: Some(Self::defined_reference_labels(ctx)),
392            atomic_spans: config.atomic_spans,
393            // Container emitters add a continuation prefix. Only the plain
394            // paragraph path can prove that no literal indentation is added.
395            wrap_code_spans: false,
396            break_link_text: config.reflow_break_link_text,
397            length_exemptions: config.length_exemptions_for_reflow(),
398            cjk_soft_break: config.cjk_soft_break,
399        }
400    }
401
402    fn should_ignore_line(
403        &self,
404        line: &str,
405        _lines: &[&str],
406        current_line: usize,
407        ctx: &crate::lint_context::LintContext,
408    ) -> bool {
409        if self.config.strict {
410            return false;
411        }
412
413        // Quick check for common patterns before expensive regex
414        let trimmed = line.trim();
415
416        // Only skip if the entire line is a URL (quick check first)
417        if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
418            return true;
419        }
420
421        // Only skip if the entire line is an image reference (quick check first)
422        if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
423            return true;
424        }
425
426        // Note: link reference definitions are handled as always-exempt (even in strict mode)
427        // in the main check loop, so they don't need to be checked here.
428
429        // Code blocks with long strings (only check if in code block)
430        if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
431            && !trimmed.is_empty()
432            && !line.contains(' ')
433            && !line.contains('\t')
434        {
435            return true;
436        }
437
438        false
439    }
440
441    /// Check if rule should skip based on provided config (used for inline config support)
442    fn should_skip_with_config(&self, ctx: &crate::lint_context::LintContext, config: &MD013Config) -> bool {
443        // Skip if content is empty
444        if ctx.content.is_empty() {
445            return true;
446        }
447
448        // For sentence-per-line, semantic-line-breaks, or normalize mode, never skip based on line length
449        if config.reflow
450            && (config.reflow_mode == ReflowMode::SentencePerLine
451                || config.reflow_mode == ReflowMode::SentencePack
452                || config.reflow_mode == ReflowMode::SemanticLineBreaks
453                || config.reflow_mode == ReflowMode::Normalize)
454        {
455            return false;
456        }
457
458        // Use the smallest applicable budget across line/heading/code-block
459        // contexts so a stricter context-specific limit doesn't get masked by
460        // the document-wide budget.
461        let min_limit = config.min_effective_line_length();
462        if min_limit.is_unlimited() {
463            return true;
464        }
465        let min_limit_bytes = min_limit.get();
466
467        // Quick check: if total content is shorter than the smallest line limit,
468        // definitely skip.
469        if ctx.content.len() <= min_limit_bytes {
470            return true;
471        }
472
473        // Skip if no line exceeds the smallest applicable limit.
474        !ctx.lines.iter().any(|line| line.byte_len > min_limit_bytes)
475    }
476
477    fn join_paragraph_lines<S: AsRef<str>>(lines: &[S], config: &MD013Config) -> String {
478        if config.reflow_mode == ReflowMode::SentencePerLine {
479            join_sentence_lines(lines, config.cjk_soft_break, &config.abbreviations_for_reflow())
480        } else {
481            join_soft_break_lines(lines, config.cjk_soft_break)
482        }
483    }
484
485    fn normalize_mode_needs_reflow<'a, I>(&self, lines: I, config: &MD013Config) -> bool
486    where
487        I: IntoIterator<Item = &'a str>,
488    {
489        let mut line_count = 0;
490        let check_length = !config.line_length.is_unlimited();
491
492        for line in lines {
493            line_count += 1;
494            if check_length && self.calculate_effective_length(line) > config.line_length.get() {
495                return true;
496            }
497        }
498
499        line_count > 1
500    }
501}
502
503impl Rule for MD013LineLength {
504    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
505        (!self.config.reflow).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(13))
506    }
507
508    fn name(&self) -> &'static str {
509        "MD013"
510    }
511
512    fn description(&self) -> &'static str {
513        "Line length should not be excessive"
514    }
515
516    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
517        // Use pre-parsed inline config from LintContext
518        let config_override = ctx.inline_config().get_rule_config("MD013");
519
520        // Apply configuration override if present
521        let effective_config = if let Some(json_config) = config_override {
522            if let Some(obj) = json_config.as_object() {
523                let mut config = self.config.clone();
524                if let Some(line_length) = obj.get("line_length").and_then(serde_json::Value::as_u64) {
525                    config.line_length = crate::types::LineLength::new(line_length as usize);
526                }
527                if let Some(code_blocks) = obj.get("code_blocks").and_then(serde_json::Value::as_bool) {
528                    config.code_blocks = code_blocks;
529                }
530                if let Some(code_spans) = obj.get("code_spans").and_then(serde_json::Value::as_bool) {
531                    config.code_spans = code_spans;
532                }
533                if let Some(tables) = obj.get("tables").and_then(serde_json::Value::as_bool) {
534                    config.tables = tables;
535                }
536                if let Some(headings) = obj.get("headings").and_then(serde_json::Value::as_bool) {
537                    config.headings = headings;
538                }
539                if let Some(math_blocks) = obj
540                    .get("math_blocks")
541                    .or_else(|| obj.get("math-blocks"))
542                    .and_then(serde_json::Value::as_bool)
543                {
544                    config.math_blocks = math_blocks;
545                }
546                if let Some(enabled) = obj
547                    .get("bracket_display_math")
548                    .or_else(|| obj.get("bracket-display-math"))
549                    .and_then(serde_json::Value::as_bool)
550                {
551                    config.bracket_display_math = enabled;
552                }
553                if let Some(blockquotes) = obj.get("blockquotes").and_then(serde_json::Value::as_bool) {
554                    config.blockquotes = blockquotes;
555                }
556                if let Some(strict) = obj.get("strict").and_then(serde_json::Value::as_bool) {
557                    config.strict = strict;
558                }
559                if let Some(stern) = obj.get("stern").and_then(serde_json::Value::as_bool) {
560                    config.stern = stern;
561                }
562                if let Some(v) = obj
563                    .get("ignore_link_urls")
564                    .or_else(|| obj.get("ignore-link-urls"))
565                    .or_else(|| obj.get("semantic_link_understanding"))
566                    .or_else(|| obj.get("semantic-link-understanding"))
567                    .and_then(serde_json::Value::as_bool)
568                {
569                    config.ignore_link_urls = v;
570                }
571                if let Some(reflow) = obj.get("reflow").and_then(serde_json::Value::as_bool) {
572                    config.reflow = reflow;
573                }
574                if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
575                    config.reflow_mode = match reflow_mode {
576                        "default" => ReflowMode::Default,
577                        "normalize" => ReflowMode::Normalize,
578                        "sentence-per-line" => ReflowMode::SentencePerLine,
579                        "sentence-pack" | "sentence_pack" => ReflowMode::SentencePack,
580                        "semantic-line-breaks" => ReflowMode::SemanticLineBreaks,
581                        _ => ReflowMode::default(),
582                    };
583                }
584                config
585            } else {
586                self.config.clone()
587            }
588        } else {
589            self.config.clone()
590        };
591
592        // Fast early return using should_skip with EFFECTIVE config (after inline overrides)
593        // But don't skip if we're in reflow mode with Normalize or SentencePerLine
594        if self.should_skip_with_config(ctx, &effective_config)
595            && !(effective_config.reflow
596                && (effective_config.reflow_mode == ReflowMode::Normalize
597                    || effective_config.reflow_mode == ReflowMode::SentencePack
598                    || effective_config.reflow_mode == ReflowMode::SentencePerLine
599                    || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
600        {
601            return Ok(Vec::new());
602        }
603
604        // Direct implementation without DocumentStructure
605        let mut warnings = Vec::new();
606
607        // Special handling: line_length = 0 means "no line length limit"
608        // Skip all line length checks, but still allow reflow if enabled
609        let skip_length_checks = effective_config.line_length.is_unlimited();
610
611        // Pre-filter lines that could be problematic to avoid processing all lines.
612        // Use the smallest applicable budget across line/heading/code-block contexts
613        // so candidates aren't dropped when a stricter context-specific budget applies.
614        let prefilter_limit = effective_config.min_effective_line_length();
615        let prefilter_skip = prefilter_limit.is_unlimited();
616        let mut candidate_lines = Vec::new();
617        if !skip_length_checks && !prefilter_skip {
618            for (line_idx, line_info) in ctx.lines.iter().enumerate() {
619                // Skip front matter - it should never be linted
620                if line_info.in_front_matter {
621                    continue;
622                }
623
624                // Quick length check first
625                if line_info.byte_len > prefilter_limit.get() {
626                    candidate_lines.push(line_idx);
627                }
628            }
629        }
630
631        // If no candidate lines and not in normalize or sentence-per-line mode, early return
632        if candidate_lines.is_empty()
633            && !(effective_config.reflow
634                && (effective_config.reflow_mode == ReflowMode::Normalize
635                    || effective_config.reflow_mode == ReflowMode::SentencePack
636                    || effective_config.reflow_mode == ReflowMode::SentencePerLine
637                    || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
638        {
639            return Ok(warnings);
640        }
641
642        let lines = ctx.raw_lines();
643
644        // Whether a 1-indexed line is part of a heading. A setext heading's text
645        // is the whole paragraph its underline ends, and the parser records the
646        // heading itself only on the last of those lines, so the earlier ones
647        // are recognized through `is_setext_heading_text`. Both are O(1)
648        // per-line fields, so check them directly at each use site instead of
649        // materializing a full-document HashSet (an extra O(n) pass and
650        // allocation on a rule that runs on virtually every file).
651        //
652        // A heading inside a blockquote is recorded apart from the line fields,
653        // so the 1-indexed line spans of those few headings are listed here.
654        let quoted_heading_spans: Vec<std::ops::RangeInclusive<usize>> = ctx
655            .headings()
656            .filter(crate::lint_context::ParsedHeading::is_blockquote)
657            .map(|heading| heading.first_line_num()..=heading.line_num)
658            .collect();
659        let is_heading_line_num = |line_number: usize| -> bool {
660            line_number
661                .checked_sub(1)
662                .and_then(|idx| ctx.lines.get(idx))
663                .is_some_and(|line| line.heading.is_some() || line.is_setext_heading_text)
664                || span_containing(&quoted_heading_spans, line_number).is_some()
665        };
666
667        // Use pre-computed table blocks from context
668        // We need this for both the table skip check AND the paragraphs check
669        let table_blocks = &ctx.table_blocks;
670        let mut table_lines_set = std::collections::HashSet::new();
671        for table in table_blocks {
672            table_lines_set.insert(table.header_line + 1);
673            table_lines_set.insert(table.delimiter_line + 1);
674            for &line in &table.content_lines {
675                table_lines_set.insert(line + 1);
676            }
677        }
678
679        let defined_references = Self::defined_reference_labels(ctx);
680
681        // Process candidate lines for line length checks
682        'line_loop: for &line_idx in &candidate_lines {
683            let line_number = line_idx + 1;
684            let line = lines[line_idx];
685
686            // Calculate actual line length (used in warning messages)
687            let effective_length = self.calculate_effective_length(line);
688
689            // Pick the context-specific limit: heading > code-block > paragraph.
690            // Headings dominate over code-block context if a setext underline ever
691            // overlaps a fenced range (defensive — these are mutually exclusive in
692            // practice, but the explicit ordering documents intent).
693            let is_heading_line = is_heading_line_num(line_number);
694            let in_code_block = ctx.line_info(line_number).is_some_and(|info| info.in_code_block);
695            let line_limit = if is_heading_line {
696                effective_config.effective_heading_line_length().get()
697            } else if in_code_block {
698                effective_config.effective_code_block_line_length().get()
699            } else {
700                effective_config.line_length.get()
701            };
702
703            // A context-specific limit of 0 means "unlimited for this context".
704            if line_limit == 0 {
705                continue;
706            }
707
708            // Stern mode: like default, but the trailing-token forgiveness is
709            // disabled — a line with whitespace that exceeds the limit is a
710            // violation even if the excess is the final token. The "unwrappable"
711            // line exemption (single token, optionally prefixed by # or >) is
712            // still honored. Strict overrides stern entirely.
713            if effective_config.stern && !effective_config.strict && is_unwrappable_line(line) {
714                continue;
715            }
716
717            // Trailing-token forgiveness: only in default mode (not strict, not stern).
718            // If the line only exceeds the limit because of a long token at the end
719            // (URL, link chain, identifier), it passes. This matches markdownlint's
720            // behavior: line.replace(/\S*$/u, "#")
721            let check_length = if effective_config.strict || effective_config.stern {
722                effective_length
723            } else {
724                match line.rfind(char::is_whitespace) {
725                    Some(pos) => {
726                        let ws_char = line[pos..].chars().next().unwrap();
727                        let prefix_end = pos + ws_char.len_utf8();
728                        self.calculate_string_length(&line[..prefix_end]) + 1
729                    }
730                    None => 1, // No whitespace — entire line is a single token
731                }
732            };
733
734            // Skip lines where the check length is within the limit
735            if check_length <= line_limit {
736                continue;
737            }
738
739            // Ignore inline link/image URLs: suppress when excess comes entirely from inline URLs.
740            // Disabled by `strict` (all forgiveness off) and by `ignore_link_urls = false`
741            // (count link/image URLs toward the line length so such lines are flagged).
742            if !effective_config.strict && effective_config.ignore_link_urls {
743                let length_without_urls = self.length_without_inline_link_urls(effective_length, line_number, ctx);
744                if length_without_urls <= line_limit {
745                    continue;
746                }
747            }
748
749            // Inline code spans cannot be wrapped, so reflow cannot shorten a line
750            // whose excess length is one. When code-span checking is disabled,
751            // suppress a violation that would fit once inline code spans are excluded.
752            if !effective_config.code_spans {
753                let code_span_width: usize = ctx
754                    .source_code_spans()
755                    .iter()
756                    .filter(|span| span.line == line_number && span.end_line == line_number)
757                    .map(|span| self.calculate_string_length(&ctx.content[span.byte_offset..span.byte_end]))
758                    .sum();
759                if effective_length.saturating_sub(code_span_width) <= line_limit {
760                    continue;
761                }
762            }
763
764            // Skip mkdocstrings and pymdown blocks (already handled by LintContext)
765            if ctx.lines[line_idx].in_mkdocstrings || ctx.lines[line_idx].in_pymdown_block {
766                continue;
767            }
768
769            // Skip MyST comments (% comment) — structural lines, not prose
770            if ctx.lines[line_idx].is_myst_comment {
771                continue;
772            }
773
774            // Link reference definitions are always exempt, even in strict mode.
775            // There's no way to shorten them without breaking the URL.
776            // Also check after stripping list markers, since list items may
777            // contain link ref defs as their content.
778            {
779                let trimmed = line.trim();
780                if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
781                    continue;
782                }
783                if is_list_item(trimmed) {
784                    let (_, content) = extract_list_marker_and_content(trimmed);
785                    let content_trimmed = content.trim();
786                    if content_trimmed.starts_with('[')
787                        && content_trimmed.contains("]:")
788                        && LINK_REF_PATTERN.is_match(content_trimmed)
789                    {
790                        continue;
791                    }
792                }
793            }
794
795            // Skip various block types efficiently
796            if !effective_config.strict {
797                // Lines whose only content is a link/image are exempt.
798                // After stripping list markers, blockquote markers, and emphasis,
799                // if only a link or image remains, there is no way to shorten it.
800                if is_standalone_link_or_image_line(ctx, line_number) {
801                    continue;
802                }
803
804                // Lines consisting entirely of HTML tags are exempt.
805                // Badge lines, images with attributes, and similar inline HTML
806                // are long due to URLs in attributes and can't be meaningfully shortened.
807                if is_html_only_line(line) {
808                    continue;
809                }
810
811                // Skip setext heading underlines
812                if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
813                    continue;
814                }
815
816                // Skip block elements according to config flags
817                // The flags mean: true = check these elements, false = skip these elements
818                // So we skip when the flag is FALSE and the line is in that element type
819                if (!effective_config.headings && is_heading_line_num(line_number))
820                    || (!effective_config.code_blocks
821                        && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
822                    || (!effective_config.tables && table_lines_set.contains(&line_number))
823                    || (!effective_config.math_blocks && self.line_is_display_math(line_number, ctx, &effective_config))
824                    || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
825                    || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
826                    || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
827                    || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
828                    || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
829                    || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
830                    || ctx.line_info(line_number).is_some_and(|info| info.in_pymdown_block)
831                {
832                    continue;
833                }
834
835                // Check if this is a paragraph/regular text line
836                // If paragraphs = false, skip lines that are NOT in special blocks
837                // Blockquote content is treated as paragraph text, so it's not
838                // included in the special blocks list here.
839                if !effective_config.paragraphs {
840                    let is_special_block = is_heading_line_num(line_number)
841                        || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
842                        || table_lines_set.contains(&line_number)
843                        || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
844                        || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
845                        || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
846                        || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
847                        || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
848                        || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
849                        || ctx
850                            .line_info(line_number)
851                            .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container);
852
853                    // Skip regular paragraph text when paragraphs = false
854                    if !is_special_block {
855                        continue;
856                    }
857                }
858
859                // Skip blockquote lines when blockquotes = false.
860                // Also skip lazy continuation lines that belong to a blockquote
861                // (lines without `>` prefix that follow a blockquote line).
862                if !effective_config.blockquotes {
863                    if ctx.lines[line_number - 1].blockquote.is_some() {
864                        continue;
865                    }
866                    // Check for lazy continuation: scan backwards through
867                    // non-blank lines to find if this paragraph started with
868                    // a blockquote marker
869                    if !line.trim().is_empty() {
870                        let mut scan = line_number.saturating_sub(2);
871                        loop {
872                            if ctx.lines[scan].blockquote.is_some() {
873                                // Found a blockquote ancestor — this is a lazy continuation
874                                continue 'line_loop;
875                            }
876                            if lines[scan].trim().is_empty() || scan == 0 {
877                                break;
878                            }
879                            scan -= 1;
880                        }
881                    }
882                }
883
884                // Skip lines that are only a URL, image ref, or link ref
885                if self.should_ignore_line(line, lines, line_idx, ctx) {
886                    continue;
887                }
888            }
889
890            // In sentence-per-line mode, check if this is a single long sentence
891            // If so, emit a warning without a fix (user must manually rephrase)
892            if effective_config.reflow_mode == ReflowMode::SentencePerLine {
893                let sentences = split_into_sentences(
894                    line.trim(),
895                    Some(&defined_references),
896                    effective_config.require_sentence_capital,
897                    effective_config.cjk_soft_break,
898                );
899                if sentences.len() == 1 {
900                    // Single sentence that's too long - warn but don't auto-fix
901                    let message = format!("Line length {effective_length} exceeds {line_limit} characters");
902
903                    let (start_line, start_col, end_line, end_col) =
904                        calculate_excess_range(line_number, line, line_limit);
905
906                    warnings.push(LintWarning {
907                        rule_name: Some(self.name().to_string()),
908                        message,
909                        line: start_line,
910                        column: start_col,
911                        end_line,
912                        end_column: end_col,
913                        severity: Severity::Warning,
914                        fix: None, // No auto-fix for long single sentences
915                    });
916                    continue;
917                }
918                // Multiple sentences will be handled by paragraph-based reflow
919                continue;
920            }
921
922            // In semantic-line-breaks mode, skip per-line checks —
923            // all reflow is handled at the paragraph level with cascading splits
924            if effective_config.reflow_mode == ReflowMode::SemanticLineBreaks {
925                continue;
926            }
927            // Sentence packing is checked against the actual paragraph replacement.
928            // An indivisible sentence may exceed the soft budget without a warning.
929            if effective_config.reflow
930                && effective_config.reflow_mode == ReflowMode::SentencePack
931                && !is_heading_line
932                && !in_code_block
933                && !table_lines_set.contains(&line_number)
934                && !self.line_is_display_math(line_number, ctx, &effective_config)
935            {
936                continue;
937            }
938
939            // Don't provide fix for individual lines when reflow is enabled
940            // Paragraph-based fixes will be handled separately
941            let fix = None;
942
943            let message = format!("Line length {effective_length} exceeds {line_limit} characters");
944
945            // Calculate precise character range for the excess portion
946            let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
947
948            warnings.push(LintWarning {
949                rule_name: Some(self.name().to_string()),
950                message,
951                line: start_line,
952                column: start_col,
953                end_line,
954                end_column: end_col,
955                severity: Severity::Warning,
956                fix,
957            });
958        }
959
960        // If reflow is enabled, generate paragraph-based fixes
961        if effective_config.reflow {
962            let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, lines);
963            let table_lines = gfm_table_lines(ctx);
964            // Merge paragraph warnings with line warnings, removing duplicates
965            for mut pw in paragraph_warnings {
966                if ctx.flavor == crate::config::MarkdownFlavor::MDG
967                    && (pw.line..=pw.end_line).any(|line_number| is_potential_mdg_step(ctx, line_number))
968                {
969                    // Keep reporting the excessive line, but do not offer a fix
970                    // whose replacement would split a Gherkin step across lines.
971                    pw.fix = None;
972                }
973                if (pw.line..=pw.end_line).any(|line_number| {
974                    let idx = line_number - 1;
975                    table_lines.get(idx).copied().unwrap_or(false) && !ctx.lines[idx].in_table_block
976                }) {
977                    // A table rumdl's table detection does not see is no
978                    // paragraph to reformat: rewrapping its header or joining
979                    // its rows would turn it back into prose, or into a
980                    // different table. The line warnings stand as they would
981                    // for any table.
982                    continue;
983                }
984                // A paragraph warning that touches a disabled line is dropped by the
985                // inline-config filter, since its fix would rewrite that line. It is
986                // still emitted so the filter credits the disable comment, and the
987                // per-line warnings stay: those on disabled lines are filtered as
988                // usual and an over-long enabled line is still reported.
989                let touches_disabled_line = (pw.line..=pw.end_line)
990                    .any(|line_number| ctx.inline_config().is_rule_disabled(self.name(), line_number));
991                if !touches_disabled_line {
992                    // Remove any line warnings that overlap with this paragraph
993                    warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
994                }
995                warnings.push(pw);
996            }
997        }
998
999        Ok(warnings)
1000    }
1001
1002    fn fixes_from_diagnostics(&self) -> bool {
1003        true
1004    }
1005
1006    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1007        // For CLI usage, apply fixes from warnings
1008        // LSP will use the warning-based fixes directly
1009        let warnings = self.check(ctx)?;
1010        let warnings =
1011            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1012
1013        // If there are no fixes, return content unchanged
1014        if !warnings.iter().any(|w| w.fix.is_some()) {
1015            return Ok(ctx.content.to_string());
1016        }
1017
1018        // Apply warning-based fixes
1019        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1020            .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
1021    }
1022
1023    fn as_any(&self) -> &dyn std::any::Any {
1024        self
1025    }
1026
1027    fn category(&self) -> RuleCategory {
1028        RuleCategory::Whitespace
1029    }
1030
1031    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1032        self.should_skip_with_config(ctx, &self.config)
1033    }
1034
1035    crate::impl_rule_config_sections!(MD013Config);
1036
1037    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1038        let mut aliases = std::collections::HashMap::new();
1039        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
1040        aliases.insert("strict_sentences".to_string(), "require-sentence-capital".to_string());
1041        aliases.insert("strict-sentences".to_string(), "require-sentence-capital".to_string());
1042        // Kept in step with the `alias` attributes on `MD013Config::ignore_link_urls`.
1043        // Serde accepts these spellings, so a config using one is honored; without
1044        // them here the key validator reports a documented, working key as unknown.
1045        aliases.insert(
1046            "semantic-link-understanding".to_string(),
1047            "ignore-link-urls".to_string(),
1048        );
1049        aliases.insert(
1050            "semantic_link_understanding".to_string(),
1051            "ignore-link-urls".to_string(),
1052        );
1053        Some(aliases)
1054    }
1055
1056    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1057    where
1058        Self: Sized,
1059    {
1060        let rule_config = MD013Config::from_document_config(config);
1061        let mut rule = Self::from_config_struct(rule_config);
1062        // Pull list-marker spacing from MD030 (via the shared serde config loader)
1063        // so reflow rewrites list items with the configured spacing rather than a
1064        // hard-coded single space.
1065        rule.list_spacing = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
1066        Box::new(rule)
1067    }
1068}
1069
1070impl MD013LineLength {
1071    /// True when `line_num` (1-indexed) sits inside a `$$` span covering more
1072    /// than one line, as seen by the byte-level math parser.
1073    fn line_in_multiline_math_span(&self, line_num: usize, ctx: &crate::lint_context::LintContext) -> bool {
1074        ctx.math_spans()
1075            .iter()
1076            .any(|span| span.is_display && span.end_line > span.line && (span.line..=span.end_line).contains(&line_num))
1077    }
1078
1079    /// True when `line_num` (1-indexed) holds nothing but display math, whether
1080    /// that is one line of a multi-line block, a delimiter line, or a whole line
1081    /// that is a single complete `$$...$$` span. This is what `math-blocks =
1082    /// false` exempts from the length check.
1083    ///
1084    /// rumdl models math twice and the two models miss different containers, so
1085    /// this consults both. `math_spans()` is byte-level and sees a block opened on
1086    /// a list marker line (`- $$`), which the line-level map cannot because that
1087    /// line does not begin with `$$`. `LineInfo::in_math_block` is line-level and
1088    /// sees a four-space-indented block inside a footnote, which the byte-level
1089    /// parser reads as an indented code block. Taking the union only ever adds
1090    /// coverage: neither signal fires on ordinary prose, and an unmatched `$$`
1091    /// opener is flagged by neither, so a stray delimiter cannot exempt the rest
1092    /// of the document.
1093    fn line_is_display_math(
1094        &self,
1095        line_num: usize,
1096        ctx: &crate::lint_context::LintContext,
1097        config: &MD013Config,
1098    ) -> bool {
1099        self.line_holds_only_multiline_math(line_num, ctx)
1100            || ctx.line_info(line_num).is_some_and(|info| info.in_math_block)
1101            || (config.bracket_display_math
1102                && ctx
1103                    .bracket_display_math_lines()
1104                    .multiline
1105                    .get(line_num.saturating_sub(1))
1106                    .copied()
1107                    .unwrap_or(false))
1108            || (config.bracket_display_math
1109                && ctx
1110                    .bracket_display_math_lines()
1111                    .standalone
1112                    .get(line_num.saturating_sub(1))
1113                    .copied()
1114                    .unwrap_or(false))
1115    }
1116
1117    /// True when a multi-line `$$` span covers `line_num` (1-indexed) and the line
1118    /// holds nothing besides that math.
1119    ///
1120    /// A delimiter line can carry Markdown outside the delimiter: `$$ trailing
1121    /// prose` closes a block and then continues in prose, and `leading prose $$`
1122    /// opens one at the end of a sentence. That prose is ordinary text and counts
1123    /// toward the line's length like any other, so exempting the whole line would
1124    /// hide arbitrarily long prose behind a delimiter. The line-level map already
1125    /// leaves such mixed lines unflagged; this is the byte-level half of the same
1126    /// judgement.
1127    fn line_holds_only_multiline_math(&self, line_num: usize, ctx: &crate::lint_context::LintContext) -> bool {
1128        let Some(info) = ctx.line_info(line_num) else {
1129            return false;
1130        };
1131        let line = info.content(ctx.content);
1132
1133        ctx.math_spans().iter().any(|span| {
1134            if !span.is_display || span.end_line <= span.line || !(span.line..=span.end_line).contains(&line_num) {
1135                return false;
1136            }
1137            if line_num == span.line {
1138                let before = line.get(..span.byte_offset.saturating_sub(info.byte_offset));
1139                if !before.is_none_or(|before| Self::only_structure_precedes_math(before, info)) {
1140                    return false;
1141                }
1142            }
1143            if line_num == span.end_line {
1144                let after = line.get(span.byte_end.saturating_sub(info.byte_offset)..);
1145                if !after.is_none_or(|after| after.trim().is_empty()) {
1146                    return false;
1147                }
1148            }
1149            true
1150        })
1151    }
1152
1153    /// True when the text before a block's opening delimiter is only the structure
1154    /// the block sits in: indentation, a blockquote marker, or the list marker
1155    /// introducing it. Such a block still owns its whole line.
1156    fn only_structure_precedes_math(before: &str, info: &crate::lint_context::LineInfo) -> bool {
1157        let after_marker =
1158            crate::utils::blockquote::parse_blockquote_prefix(before).map_or(before, |prefix| prefix.content);
1159        if after_marker.trim().is_empty() {
1160            return true;
1161        }
1162        info.list_item.as_ref().is_some_and(|item| {
1163            let mut chars = before.chars();
1164            chars.by_ref().take(item.content_column).count() == item.content_column && chars.as_str().trim().is_empty()
1165        })
1166    }
1167
1168    /// True when `line_num` (1-indexed) falls inside a display-math block that
1169    /// spans more than one line.
1170    ///
1171    /// Line breaks carry meaning inside such a block: a TeX `%` comment runs to
1172    /// the end of its line, so joining the lines pulls whatever followed on later
1173    /// lines into the comment and drops it from the rendered equation, which can
1174    /// also leave an environment unclosed. Reflow therefore leaves these blocks
1175    /// alone regardless of the `math_blocks` setting, which governs only whether
1176    /// their length is reported.
1177    ///
1178    /// This is `line_is_display_math` minus the case of a whole line that is one
1179    /// complete `$$...$$` span, which
1180    /// [`crate::utils::text_reflow::is_self_contained_display_math_line`]
1181    /// recognizes. Such a line is a block of its own: it has no internal line
1182    /// breaks to lose, and reflow keeps it on the line it was written on
1183    /// through that recognizer rather than through this one.
1184    fn line_in_multiline_math_block(
1185        &self,
1186        line_num: usize,
1187        ctx: &crate::lint_context::LintContext,
1188        config: &MD013Config,
1189    ) -> bool {
1190        self.line_in_multiline_math_span(line_num, ctx)
1191            || ctx.line_info(line_num).is_some_and(|info| {
1192                info.in_math_block && !is_self_contained_display_math_line(info.content(ctx.content))
1193            })
1194            || (config.bracket_display_math
1195                && ctx
1196                    .bracket_display_math_lines()
1197                    .multiline
1198                    .get(line_num.saturating_sub(1))
1199                    .copied()
1200                    .unwrap_or(false))
1201    }
1202
1203    fn line_is_standalone_bracket_math(
1204        &self,
1205        line_num: usize,
1206        ctx: &crate::lint_context::LintContext,
1207        config: &MD013Config,
1208    ) -> bool {
1209        config.bracket_display_math
1210            && ctx
1211                .bracket_display_math_lines()
1212                .standalone
1213                .get(line_num.saturating_sub(1))
1214                .copied()
1215                .unwrap_or(false)
1216    }
1217
1218    /// True when `line_num` (1-based) sits inside a structure whose lines must be
1219    /// preserved verbatim (code block, front matter, HTML/JSX/MDX block, MkDocs
1220    /// container, div marker, multi-line math block, ...). Used to keep blockquote
1221    /// reflow from touching quoted-looking text embedded in such structures.
1222    fn line_in_verbatim_context(
1223        &self,
1224        line_num: usize,
1225        ctx: &crate::lint_context::LintContext,
1226        config: &MD013Config,
1227    ) -> bool {
1228        if self.line_in_multiline_math_block(line_num, ctx, config) {
1229            return true;
1230        }
1231        ctx.line_info(line_num).is_some_and(|info| {
1232            info.in_code_block
1233                || info.in_front_matter
1234                || info.in_html_block
1235                || info.in_html_comment
1236                || info.in_esm_block
1237                || info.in_jsx_expression
1238                || info.in_jsx_block
1239                || info.in_mdx_comment
1240                || info.in_mkdocstrings
1241                || info.in_pymdown_block
1242                || info.in_mkdocs_container()
1243                || info.is_div_marker
1244        })
1245    }
1246
1247    fn is_blockquote_content_boundary(
1248        &self,
1249        content: &str,
1250        line_num: usize,
1251        ctx: &crate::lint_context::LintContext,
1252        config: &MD013Config,
1253    ) -> bool {
1254        // A code span still open at the end of the line above proves the
1255        // parser read on into this one, whatever it starts with.
1256        if line_num >= 2 && line_ends_in_code_span(ctx, line_num - 2) {
1257            return false;
1258        }
1259
1260        let trimmed = content.trim();
1261
1262        trimmed.is_empty()
1263            || self.line_in_verbatim_context(line_num, ctx, config)
1264            || trimmed.starts_with('#')
1265            || trimmed.starts_with("```")
1266            || trimmed.starts_with("~~~")
1267            || trimmed.starts_with('>')
1268            || TableUtils::is_potential_table_row_with_flavor(content, ctx.flavor)
1269            // A GFM table's rows run on without pipes until a blank line or
1270            // another block, so a quoted row can hold nothing a table needs.
1271            || ctx.lines.get(line_num - 1).is_some_and(|line| line.in_table_block)
1272            || is_list_item(trimmed)
1273            || opens_bare_item(ctx, line_num - 1)
1274            || is_horizontal_rule(content)
1275            // A setext underline ends the quoted paragraph. The text decides
1276            // it: under quoted paragraph text the run is an underline, a dash
1277            // run anywhere else is a thematic break, and stopping at an equals
1278            // run that is neither only leaves text unreflowed.
1279            || is_setext_underline_content(content)
1280            || (trimmed.starts_with('[') && content.contains("]:"))
1281            || is_template_directive_only(content)
1282            || is_standalone_attr_list(content)
1283            || is_snippet_block_delimiter(content)
1284            || is_github_alert_marker(trimmed)
1285            || is_html_only_line(content)
1286            || self.line_is_standalone_bracket_math(line_num, ctx, config)
1287            || standalone_link_ends_paragraph(ctx, line_num, config)
1288    }
1289
1290    fn generate_blockquote_paragraph_fix(
1291        &self,
1292        ctx: &crate::lint_context::LintContext,
1293        config: &MD013Config,
1294        lines: &[&str],
1295        start_idx: usize,
1296        line_ending: &str,
1297    ) -> (Option<LintWarning>, usize) {
1298        let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
1299            return (None, start_idx + 1);
1300        };
1301        let target_level = start_bq.nesting_level;
1302        let defined_references = Self::defined_reference_labels(ctx);
1303
1304        let mut collected: Vec<CollectedBlockquoteLine> = Vec::new();
1305        let mut i = start_idx;
1306
1307        while i < lines.len() {
1308            if let Some(last) = collected.last()
1309                && has_hard_break(&last.data.content)
1310                && !line_ends_in_code_span(ctx, last.line_idx)
1311            {
1312                break;
1313            }
1314
1315            let line_num = i + 1;
1316            if line_num > ctx.lines.len() {
1317                break;
1318            }
1319
1320            if lines[i].trim().is_empty() {
1321                break;
1322            }
1323
1324            let line_bq = ctx.lines[i].blockquote.as_deref();
1325            if let Some(bq) = line_bq {
1326                if bq.nesting_level != target_level {
1327                    break;
1328                }
1329
1330                if self.is_blockquote_content_boundary(&bq.content, line_num, ctx, config) {
1331                    break;
1332                }
1333
1334                collected.push(CollectedBlockquoteLine {
1335                    line_idx: i,
1336                    data: BlockquoteLineData::explicit(blockquote_continuation_line(ctx, i, bq, 0), bq.prefix.clone()),
1337                });
1338                i += 1;
1339                continue;
1340            }
1341
1342            let lazy_content = lines[i].trim_start();
1343            if self.is_blockquote_content_boundary(lazy_content, line_num, ctx, config) {
1344                break;
1345            }
1346
1347            // A lazy line has no container indentation of its own, so a code
1348            // span crossing into it keeps all of its leading whitespace.
1349            let strip = continuation_indent_to_strip(ctx, i, lines[i].len() - lazy_content.len(), 0);
1350            collected.push(CollectedBlockquoteLine {
1351                line_idx: i,
1352                data: BlockquoteLineData::lazy(restore_code_span_line_end(
1353                    ctx,
1354                    i,
1355                    trim_preserving_hard_break(&lines[i][strip..]),
1356                )),
1357            });
1358            i += 1;
1359        }
1360
1361        if collected.is_empty() {
1362            return (None, start_idx + 1);
1363        }
1364
1365        let next_idx = i;
1366        let paragraph_start = collected[0].line_idx;
1367        let end_line = collected[collected.len() - 1].line_idx;
1368        let line_data: Vec<BlockquoteLineData> = collected.iter().map(|l| l.data.clone()).collect();
1369        let pieces: Vec<&str> = line_data.iter().map(|d| d.content.as_str()).collect();
1370        if code_span_runs_into_indentation(&pieces) {
1371            return (None, next_idx);
1372        }
1373        let paragraph_text = Self::join_paragraph_lines(&pieces, config);
1374
1375        // A colon-led line with a line of the paragraph before it opens a
1376        // definition, and joining the lines would flatten the definition list
1377        // into prose. The paragraph's first line is prose whatever it starts
1378        // with, since a definition needs a term on the line before it.
1379        let contains_definition_list = line_data
1380            .iter()
1381            .skip(1)
1382            .any(|d| crate::utils::text_reflow::is_definition_list_marker(&d.content));
1383        if contains_definition_list || holds_definition_list(ctx, paragraph_start, end_line) {
1384            return (None, next_idx);
1385        }
1386
1387        let contains_snippets = line_data.iter().any(|d| is_snippet_block_delimiter(&d.content));
1388        if contains_snippets {
1389            return (None, next_idx);
1390        }
1391
1392        let needs_reflow = match config.reflow_mode {
1393            ReflowMode::SentencePack => true,
1394            ReflowMode::Normalize => {
1395                self.normalize_mode_needs_reflow(line_data.iter().map(|d| d.content.as_str()), config)
1396            }
1397            ReflowMode::SentencePerLine => {
1398                let sentences = split_into_sentences(
1399                    &paragraph_text,
1400                    Some(&defined_references),
1401                    config.require_sentence_capital,
1402                    config.cjk_soft_break,
1403                );
1404                sentences.len() > 1 || line_data.len() > 1
1405            }
1406            ReflowMode::SemanticLineBreaks => {
1407                let sentences = split_into_sentences(
1408                    &paragraph_text,
1409                    Some(&defined_references),
1410                    config.require_sentence_capital,
1411                    config.cjk_soft_break,
1412                );
1413                sentences.len() > 1
1414                    || line_data.len() > 1
1415                    || collected
1416                        .iter()
1417                        .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get())
1418            }
1419            ReflowMode::Default => collected
1420                .iter()
1421                .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get()),
1422        };
1423
1424        if !needs_reflow {
1425            return (None, next_idx);
1426        }
1427
1428        let fallback_prefix = start_bq.prefix.clone();
1429        // Text under a marker with nothing after it is inside the item or outside
1430        // it according to how far its first line is indented. Rewrapping under
1431        // the prefix most lines happen to share could move the first line across
1432        // that boundary, so the first line's prefix is kept.
1433        let explicit_prefix = if paragraph_start > 0 && opens_bare_item(ctx, paragraph_start - 1) {
1434            line_data[0].prefix.clone().unwrap_or(fallback_prefix)
1435        } else {
1436            dominant_blockquote_prefix(&line_data, &fallback_prefix)
1437        };
1438        let continuation_style = blockquote_continuation_style(&line_data);
1439
1440        let reflow_line_length = if config.line_length.is_unlimited() {
1441            usize::MAX
1442        } else {
1443            config
1444                .line_length
1445                .get()
1446                .saturating_sub(self.calculate_string_length(&explicit_prefix))
1447                .max(1)
1448        };
1449
1450        let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
1451        if config.reflow_mode == ReflowMode::SentencePack
1452            && continuation_style == crate::utils::text_reflow::BlockquoteContinuationStyle::Lazy
1453            && !config.line_length.is_unlimited()
1454        {
1455            reflow_options.line_length = config.line_length.get();
1456            reflow_options.first_line_length = Some(reflow_line_length);
1457        }
1458
1459        let reflowed_with_style =
1460            reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &reflow_options);
1461
1462        if reflowed_with_style.is_empty() {
1463            return (None, next_idx);
1464        }
1465
1466        let reflowed_text = reflowed_with_style.join(line_ending);
1467
1468        let start_range = ctx.whole_line_byte_range(paragraph_start + 1);
1469        let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1470            ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
1471        } else {
1472            ctx.whole_line_byte_range(end_line + 1)
1473        };
1474        let byte_range = start_range.start..end_range.end;
1475
1476        let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1477            format!("{reflowed_text}{line_ending}")
1478        } else {
1479            reflowed_text
1480        };
1481
1482        let original_text = &ctx.content[byte_range.clone()];
1483        if original_text == replacement {
1484            return (None, next_idx);
1485        }
1486
1487        let (warning_line, warning_end_line) = match config.reflow_mode {
1488            ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1489            ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks | ReflowMode::SentencePack => {
1490                (paragraph_start + 1, end_line + 1)
1491            }
1492            ReflowMode::Default => {
1493                let violating_line = collected
1494                    .iter()
1495                    .find(|line| self.calculate_effective_length(lines[line.line_idx]) > config.line_length.get())
1496                    .map_or(paragraph_start + 1, |line| line.line_idx + 1);
1497                (violating_line, violating_line)
1498            }
1499        };
1500
1501        let warning = LintWarning {
1502            rule_name: Some(self.name().to_string()),
1503            message: match config.reflow_mode {
1504                ReflowMode::SentencePack => "Paragraph should pack complete sentences".to_string(),
1505                ReflowMode::Normalize => format!(
1506                    "Paragraph could be normalized to use line length of {} characters",
1507                    config.line_length.get()
1508                ),
1509                ReflowMode::SentencePerLine => {
1510                    let num_sentences = split_into_sentences(
1511                        &paragraph_text,
1512                        Some(&defined_references),
1513                        config.require_sentence_capital,
1514                        config.cjk_soft_break,
1515                    )
1516                    .len();
1517                    if line_data.len() == 1 {
1518                        format!("Line contains {num_sentences} sentences (one sentence per line required)")
1519                    } else {
1520                        let num_lines = line_data.len();
1521                        format!(
1522                            "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
1523                        )
1524                    }
1525                }
1526                ReflowMode::SemanticLineBreaks => {
1527                    let num_sentences = split_into_sentences(
1528                        &paragraph_text,
1529                        Some(&defined_references),
1530                        config.require_sentence_capital,
1531                        config.cjk_soft_break,
1532                    )
1533                    .len();
1534                    format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
1535                }
1536                ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1537            },
1538            line: warning_line,
1539            column: 1,
1540            end_line: warning_end_line,
1541            end_column: lines[warning_end_line.saturating_sub(1)].chars().count() + 1,
1542            severity: Severity::Warning,
1543            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1544        };
1545
1546        (Some(warning), next_idx)
1547    }
1548
1549    /// Reflow a single list item that lives inside a blockquote.
1550    ///
1551    /// The blockquote paragraph reflow treats a list marker as a content boundary,
1552    /// so `> - long item ...` is never wrapped even though the identical top-level
1553    /// item is. This handles that case: it collects one tight, prose-only list item
1554    /// at the starting blockquote level, reflows the item body to the configured
1555    /// width, and re-emits it with the blockquote prefix preserved and continuation
1556    /// lines aligned under the list content.
1557    ///
1558    /// Sibling and nested list items end collection and are reflowed independently
1559    /// by the caller's outer loop (a nested item carries its indent folded into the
1560    /// blockquote prefix, so it reflows correctly on its own). Items that are not
1561    /// simple tight prose - those embedding a code block, table, fence, or hard
1562    /// break - are left untouched (`None`) and the cursor still advances past the
1563    /// whole item so its inner lines are never reprocessed as loose prose.
1564    fn generate_blockquote_list_item_fix(
1565        &self,
1566        ctx: &crate::lint_context::LintContext,
1567        config: &MD013Config,
1568        lines: &[&str],
1569        start_idx: usize,
1570        line_ending: &str,
1571        // One entry per document line, true where a code span crosses one of the
1572        // line's boundaries. The caller reads them off one pass over the document
1573        // and shares them across every item, so an item costs no pass of its own.
1574        code_span_touches: &[bool],
1575    ) -> (Option<LintWarning>, usize) {
1576        use crate::utils::blockquote::effective_indent_in_blockquote;
1577
1578        let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
1579            return (None, start_idx + 1);
1580        };
1581
1582        let defined_references = Self::defined_reference_labels(ctx);
1583
1584        // A `>`-prefixed line can be marked as a blockquote even inside a fenced code
1585        // block (or other verbatim structure); such content must never be reflowed.
1586        if self.line_in_verbatim_context(start_idx + 1, ctx, config) {
1587            return (None, start_idx + 1);
1588        }
1589
1590        let target_level = start_bq.nesting_level;
1591
1592        // The marker line carries the canonical blockquote prefix: its content begins
1593        // with the list marker, so no list indent has been folded into the prefix.
1594        let bq_prefix = start_bq.prefix.clone();
1595
1596        // A thematic break opens with what looks like a bullet marker (`- - -`).
1597        // It is not a list item, and reflowing it as prose destroys the break.
1598        // The top-level reflow path applies the same exemption.
1599        if is_horizontal_rule(&start_bq.content) {
1600            return (None, start_idx + 1);
1601        }
1602
1603        let (marker, first_body) = extract_list_marker_and_content(&start_bq.content);
1604        if marker.is_empty() {
1605            return (None, start_idx + 1);
1606        }
1607        let marker_width = marker.chars().count();
1608
1609        // Continuation lines of a checkbox item align under the bullet+checkbox, but
1610        // are recognized from the bullet width, matching the top-level list reflow.
1611        let base_marker_width = ["[ ] ", "[x] ", "[X] "]
1612            .iter()
1613            .find_map(|cb| marker.find(*cb))
1614            .unwrap_or(marker_width);
1615
1616        // Collect the item: the marker line plus its tight prose continuation lines.
1617        // `end_idx` always tracks the last consumed line so the cursor advances past
1618        // the entire item, even when it turns out to be too complex to reflow safely.
1619        // The item's content column within the quote's content, which a code
1620        // span crossing into a continuation line leaves in place.
1621        let item_content_col = ctx.lines[start_idx].list_item.as_ref().map_or(0, |item| {
1622            item.content_column
1623                .saturating_sub(blockquote_content_start(&start_bq.prefix))
1624        });
1625        let ends_with_hard_break = |piece: &str, idx: usize| has_hard_break(piece) && !line_ends_in_code_span(ctx, idx);
1626        let first_piece = restore_code_span_line_end(ctx, start_idx, trim_preserving_hard_break(&first_body));
1627        let mut simple = !ends_with_hard_break(&first_piece, start_idx);
1628        let mut body_pieces: Vec<String> = vec![first_piece];
1629        let mut end_idx = start_idx;
1630        let mut i = start_idx + 1;
1631
1632        while i < lines.len() {
1633            let Some(bq) = ctx.lines[i].blockquote.as_deref() else {
1634                // A blank line ends the item.
1635                if lines[i].trim().is_empty() {
1636                    break;
1637                }
1638                // A lazy continuation (no `>` marker) is too ambiguous to reflow
1639                // safely. Consume the whole lazy run into this item's span and leave
1640                // the item untouched, so the caller does not reflow the continuation
1641                // in isolation and leave the marker line partially fixed.
1642                simple = false;
1643                while i < lines.len() && ctx.lines[i].blockquote.is_none() && !lines[i].trim().is_empty() {
1644                    end_idx = i;
1645                    i += 1;
1646                }
1647                break;
1648            };
1649            if bq.nesting_level != target_level {
1650                break;
1651            }
1652
1653            let content = bq.content.as_str();
1654            if content.trim().is_empty() {
1655                // Blank quoted line ends the tight paragraph. A following indented
1656                // paragraph (loose item) is reflowed on its own by the prose path.
1657                break;
1658            }
1659
1660            // A code span still open at the end of the line above proves the
1661            // parser read on into this line, so it is text of this item however
1662            // it is spelled and however far it is indented.
1663            let continues_code_span = line_ends_in_code_span(ctx, i - 1);
1664            let eff_indent = effective_indent_in_blockquote(lines[i], target_level, 0);
1665            if eff_indent < base_marker_width && !continues_code_span {
1666                // Dedented: a sibling list item or text outside this item. Stop here
1667                // and let the outer loop classify it.
1668                break;
1669            }
1670            if is_list_item(content) && !continues_code_span {
1671                // A nested list item: its own item, handled independently. A
1672                // line a code span runs on into is text of this item instead.
1673                break;
1674            }
1675
1676            // An embedded structure (code block, table, fence, nested quote, ...)
1677            // means the item is not simple prose: keep consuming so the cursor clears
1678            // the whole structure, but do not produce a fix.
1679            if self.is_blockquote_content_boundary(content, i + 1, ctx, config) {
1680                simple = false;
1681            }
1682
1683            // A line short of the item's content column continues its paragraph
1684            // lazily, and the item takes none of its indentation.
1685            let content_col_reached = if eff_indent >= item_content_col {
1686                item_content_col
1687            } else {
1688                0
1689            };
1690            let piece = blockquote_continuation_line(ctx, i, bq, content_col_reached);
1691            if ends_with_hard_break(&piece, i) {
1692                simple = false;
1693            }
1694            body_pieces.push(piece);
1695            end_idx = i;
1696            i += 1;
1697        }
1698
1699        let next_idx = end_idx + 1;
1700
1701        if !simple || holds_definition_list(ctx, start_idx, end_idx) || code_span_runs_into_indentation(&body_pieces) {
1702            return (None, next_idx);
1703        }
1704
1705        let exceeds_limit =
1706            || (start_idx..=end_idx).any(|idx| self.calculate_effective_length(lines[idx]) > config.line_length.get());
1707        let body_text = Self::join_paragraph_lines(&body_pieces, config);
1708        let body_text = trim_breakable_whitespace(&body_text);
1709
1710        // A body line that is one whole `$$...$$` expression renders as a display
1711        // block, so it holds a line of its own and the prose on either side of it
1712        // is reflowed separately. So does a link reference definition opening the
1713        // body, which ends at its line: text joined onto its destination would turn
1714        // it into visible text. Each segment carries whether it is such a line,
1715        // kept as written. A line touched by a code span crossing one of its
1716        // boundaries is code, not a display block. The pieces are the consecutive
1717        // lines from `start_idx`, one piece each.
1718        let body_segments: Vec<(bool, Vec<&str>)> = {
1719            let mut segments: Vec<(bool, Vec<&str>)> = Vec::new();
1720            let mut current: Vec<&str> = Vec::new();
1721            let mut in_leading_definitions = true;
1722            for (offset, piece) in body_pieces.iter().enumerate() {
1723                in_leading_definitions &= may_be_link_ref_def(piece);
1724                let is_display_math = (is_self_contained_display_math_line(piece)
1725                    || self.line_is_standalone_bracket_math(start_idx + offset + 1, ctx, config))
1726                    && !line_touches_multiline_code_span(code_span_touches, start_idx + offset + 1);
1727                if in_leading_definitions || is_display_math {
1728                    if !current.is_empty() {
1729                        segments.push((false, std::mem::take(&mut current)));
1730                    }
1731                    segments.push((true, vec![piece.trim_start()]));
1732                } else {
1733                    current.push(piece.as_str());
1734                }
1735            }
1736            if !current.is_empty() {
1737                segments.push((false, current));
1738            }
1739            segments
1740        };
1741        let holds_own_line = body_segments.iter().any(|(own_line, _)| *own_line);
1742
1743        // Some bodies cannot be shortened and must stay verbatim, matching the
1744        // exemptions the top-level list reflow applies: link reference definitions
1745        // always, and (in non-strict mode) standalone links/images and HTML-only
1746        // lines. Reflowing a link reference definition would split it after the
1747        // colon/URL and break the definition.
1748        let is_link_ref_def =
1749            body_text.starts_with('[') && body_text.contains("]:") && LINK_REF_PATTERN.is_match(body_text);
1750        let raw_marker_line = lines[start_idx];
1751        let body_is_unwrappable = is_link_ref_def
1752            || standalone_link_ends_paragraph(ctx, start_idx + 1, config)
1753            || (!config.strict && is_html_only_line(raw_marker_line));
1754        if body_is_unwrappable {
1755            return (None, next_idx);
1756        }
1757
1758        let needs_reflow = match config.reflow_mode {
1759            ReflowMode::SentencePack => true,
1760            ReflowMode::Normalize => body_pieces.len() > 1 || exceeds_limit(),
1761            ReflowMode::Default => exceeds_limit(),
1762            ReflowMode::SentencePerLine => {
1763                split_into_sentences(
1764                    body_text,
1765                    Some(&defined_references),
1766                    config.require_sentence_capital,
1767                    config.cjk_soft_break,
1768                )
1769                .len()
1770                    > 1
1771                    || body_pieces.len() > 1
1772            }
1773            ReflowMode::SemanticLineBreaks => {
1774                split_into_sentences(
1775                    body_text,
1776                    Some(&defined_references),
1777                    config.require_sentence_capital,
1778                    config.cjk_soft_break,
1779                )
1780                .len()
1781                    > 1
1782                    || exceeds_limit()
1783            }
1784        };
1785        if !needs_reflow {
1786            return (None, next_idx);
1787        }
1788
1789        // Apply MD030 list-marker spacing in the spacing-normalizing modes, mirroring
1790        // the top-level list reflow: derive the post-marker spacing from MD030 and let
1791        // the continuation indent follow the resulting content width. Default MD030 (a
1792        // single space) leaves the marker unchanged. MkDocs keeps its rigid indent.
1793        let (marker, marker_width) = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
1794            && !ctx.flavor.requires_strict_list_indent()
1795        {
1796            let is_ordered = marker.starts_with(|c: char| c.is_ascii_digit());
1797            // Bullet/number portion only (e.g. `-`, `1.`); the checkbox is content.
1798            let bullet = marker.split(' ').next().unwrap_or("");
1799            let bullet_len = bullet.chars().count();
1800            let checkbox_tail = &marker[base_marker_width..];
1801            // Decide single- vs multi-line spacing from the rewritten shape. This path
1802            // only handles a single tight prose paragraph (structural or multi-paragraph
1803            // items set `simple = false` and bail out above), so the emitted item stays
1804            // multi-line solely when the joined body wraps past one line. A multi-line
1805            // *source* that collapses onto the marker line must use MD030's single-line
1806            // spacing, matching the top-level reflow. The wrap test measures at the
1807            // single-line content column so it does not depend on the spacing chosen here.
1808            let single_col = self.calculate_string_length(&bq_prefix)
1809                + bullet_len
1810                + self.list_spacing.expected_spaces(is_ordered, false, bullet_len)
1811                + checkbox_tail.chars().count();
1812            // A display-math line or a definition always holds a line of its own,
1813            // so an item that carries one spans several lines whatever the joined
1814            // body measures.
1815            let is_multi = holds_own_line
1816                || (!body_text.is_empty()
1817                    && self.calculate_effective_length(&format!("{}{body_text}", " ".repeat(single_col)))
1818                        > config.line_length.effective_limit());
1819            let spaces = self.list_spacing.expected_spaces(is_ordered, is_multi, bullet_len);
1820            let new_marker = format!("{bullet}{}{checkbox_tail}", " ".repeat(spaces));
1821            let width = new_marker.chars().count();
1822            (new_marker, width)
1823        } else {
1824            (marker, marker_width)
1825        };
1826
1827        // The fix rewrites only this paragraph, so every later line of the quote
1828        // keeps its indentation. A marker that moves the item's content column past
1829        // one of them changes the block that line belongs to: `>   7. b` beside
1830        // `> -   item` opens a list after the item, and beside `> - item` a list
1831        // nested in it. Such an item keeps its marker as written.
1832        let (marker, marker_width) = match source_list_marker(&start_bq.content) {
1833            Some(source) => {
1834                let source_col = if item_content_col > 0 {
1835                    item_content_col
1836                } else {
1837                    source.content_col
1838                };
1839                let (low, high) = (source_col.min(marker_width), source_col.max(marker_width));
1840                let moves_a_later_line = ctx.lines[next_idx..]
1841                    .iter()
1842                    .zip(&lines[next_idx..])
1843                    .map_while(|(info, line)| {
1844                        info.blockquote
1845                            .as_deref()
1846                            .filter(|bq| bq.nesting_level >= target_level)
1847                            .map(|bq| (bq, line))
1848                    })
1849                    .any(|(bq, line)| {
1850                        let indent = effective_indent_in_blockquote(line, target_level, 0);
1851                        bq.nesting_level == target_level
1852                            && !bq.content.trim().is_empty()
1853                            && (low..high).contains(&indent)
1854                    });
1855                if moves_a_later_line {
1856                    (source.text, source.content_col)
1857                } else {
1858                    (marker, marker_width)
1859                }
1860            }
1861            None => (marker, marker_width),
1862        };
1863
1864        let prefix_width = self.calculate_string_length(&bq_prefix) + self.calculate_string_length(&marker);
1865        let reflow_line_length = if config.line_length.is_unlimited() {
1866            usize::MAX
1867        } else {
1868            config.line_length.get().saturating_sub(prefix_width).max(1)
1869        };
1870
1871        let reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
1872
1873        // A segment holding a line of its own is emitted as written; a prose
1874        // segment is joined and reflowed on its own, so the prose above and below
1875        // it wraps within its own paragraph.
1876        let mut reflowed: Vec<String> = Vec::new();
1877        let mut rewritten_sentence_count = 0;
1878        for (own_line, segment) in &body_segments {
1879            if *own_line {
1880                reflowed.push(segment[0].to_string());
1881                continue;
1882            }
1883            let segment_text = Self::join_paragraph_lines(segment, config);
1884            let segment_text = trim_breakable_whitespace(&segment_text);
1885            if segment_text.is_empty() {
1886                continue;
1887            }
1888            let segment_reflowed = crate::utils::text_reflow::reflow_line(segment_text, &reflow_options);
1889            if matches!(
1890                config.reflow_mode,
1891                ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks
1892            ) && segment_reflowed.iter().map(String::as_str).ne(segment.iter().copied())
1893            {
1894                rewritten_sentence_count += split_into_sentences(
1895                    segment_text,
1896                    Some(&defined_references),
1897                    config.require_sentence_capital,
1898                    config.cjk_soft_break,
1899                )
1900                .len();
1901            }
1902            reflowed.extend(segment_reflowed);
1903        }
1904        if reflowed.is_empty() {
1905            return (None, next_idx);
1906        }
1907
1908        let continuation_indent = " ".repeat(marker_width);
1909        let reflowed_text = reflowed
1910            .iter()
1911            .enumerate()
1912            .map(|(idx, line)| {
1913                if idx == 0 {
1914                    format!("{bq_prefix}{marker}{line}")
1915                } else {
1916                    format!("{bq_prefix}{continuation_indent}{line}")
1917                }
1918            })
1919            .collect::<Vec<_>>()
1920            .join(line_ending);
1921
1922        let start_range = ctx.whole_line_byte_range(start_idx + 1);
1923        let end_range = if end_idx == lines.len() - 1 && !ctx.content.ends_with('\n') {
1924            ctx.line_text_byte_range(end_idx + 1, 1, lines[end_idx].len() + 1)
1925        } else {
1926            ctx.whole_line_byte_range(end_idx + 1)
1927        };
1928        let byte_range = start_range.start..end_range.end;
1929
1930        let replacement = if end_idx < lines.len() - 1 || ctx.content.ends_with('\n') {
1931            format!("{reflowed_text}{line_ending}")
1932        } else {
1933            reflowed_text
1934        };
1935
1936        let original_text = &ctx.content[byte_range.clone()];
1937        if original_text == replacement {
1938            return (None, next_idx);
1939        }
1940
1941        let message = match config.reflow_mode {
1942            ReflowMode::SentencePack => "List item should pack complete sentences".to_string(),
1943            ReflowMode::Normalize => format!(
1944                "Paragraph could be normalized to use line length of {} characters",
1945                config.line_length.get()
1946            ),
1947            ReflowMode::SentencePerLine => {
1948                let num_sentences = rewritten_sentence_count;
1949                format!("List item should have one sentence per line (found {num_sentences} sentences)")
1950            }
1951            ReflowMode::SemanticLineBreaks => {
1952                let num_sentences = rewritten_sentence_count;
1953                format!("List item should use semantic line breaks ({num_sentences} sentences)")
1954            }
1955            ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1956        };
1957
1958        let warning = LintWarning {
1959            rule_name: Some(self.name().to_string()),
1960            message,
1961            line: start_idx + 1,
1962            column: 1,
1963            end_line: end_idx + 1,
1964            end_column: lines[end_idx].chars().count() + 1,
1965            severity: Severity::Warning,
1966            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1967        };
1968
1969        (Some(warning), next_idx)
1970    }
1971
1972    /// Generate paragraph-based fixes
1973    fn generate_paragraph_fixes(
1974        &self,
1975        ctx: &crate::lint_context::LintContext,
1976        config: &MD013Config,
1977        lines: &[&str],
1978    ) -> Vec<LintWarning> {
1979        let mut warnings = Vec::new();
1980        let defined_references = Self::defined_reference_labels(ctx);
1981        // A line touched by a code span crossing one of its boundaries is code
1982        // however it is spelled, so a `$$...$$` expression on one is no display
1983        // block.
1984        let code_span_touches = crate::utils::text_reflow::lines_touching_multiline_code_span(ctx.content);
1985
1986        // The line-based list index can end an item early at table-like lazy
1987        // continuation text. CommonMark still owns later indented paragraphs
1988        // in that item, so retain their indentation using the parsed ranges.
1989        let mut parsed_items = Vec::new();
1990        if ctx.flavor == crate::config::MarkdownFlavor::Standard {
1991            let mut depth = 0usize;
1992            for (event, range) in pulldown_cmark::Parser::new_ext(
1993                ctx.content,
1994                crate::utils::parser_options::rumdl_parser_options_for(ctx.content),
1995            )
1996            .into_offset_iter()
1997            {
1998                match event {
1999                    pulldown_cmark::Event::Start(pulldown_cmark::Tag::Item) => {
2000                        if depth == 0 {
2001                            parsed_items.push(range);
2002                        }
2003                        depth += 1;
2004                    }
2005                    pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Item) => depth -= 1,
2006                    _ => {}
2007                }
2008            }
2009        }
2010        let in_list = |line_idx: usize| {
2011            let offset = ctx.lines[line_idx].byte_offset;
2012            let idx = parsed_items.partition_point(|range| range.start <= offset);
2013            ctx.is_in_list_block(line_idx + 1) || (idx > 0 && parsed_items[idx - 1].contains(&offset))
2014        };
2015
2016        // Detect the content's line ending style to preserve it in replacements.
2017        // The LSP receives content from editors which may use CRLF (Windows).
2018        // Replacements must match the original line endings to avoid false positives.
2019        let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
2020
2021        // A setext heading inside a blockquote is a heading, not a paragraph:
2022        // its text lines, as 1-indexed spans, are left as written for the same
2023        // reason as an unquoted one. The underline ends a quoted paragraph on its
2024        // own, so no span covers it.
2025        let quoted_setext_spans: Vec<std::ops::RangeInclusive<usize>> = ctx
2026            .headings()
2027            .filter(|heading| heading.is_blockquote() && heading.is_setext())
2028            .map(|heading| heading.first_line_num()..=heading.line_num)
2029            .collect();
2030
2031        let mut i = 0;
2032        while i < lines.len() {
2033            let line_num = i + 1;
2034
2035            if let Some(span) = span_containing(&quoted_setext_spans, line_num) {
2036                i = *span.end();
2037                continue;
2038            }
2039
2040            // No paragraph starts on a line a code span runs on into. A
2041            // collector that leaves the paragraph above as written stops short
2042            // of the rest of it, and reflowing that rest on its own would
2043            // rewrite the whitespace the span holds.
2044            if i > 0 && line_ends_in_code_span(ctx, i - 1) {
2045                i += 1;
2046                continue;
2047            }
2048
2049            // Handle blockquote paragraphs with style-preserving reflow.
2050            // Skip blockquotes when blockquotes=false or paragraphs=false
2051            if line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some() {
2052                if !config.blockquotes || !config.paragraphs {
2053                    // Skip past all blockquote lines (explicit and lazy continuations).
2054                    // A lazy continuation is a non-blank line without `>` that follows
2055                    // a blockquote line and isn't a structural element.
2056                    let mut saw_explicit_bq = false;
2057                    while i < lines.len() && i < ctx.lines.len() {
2058                        if ctx.lines[i].blockquote.is_some() {
2059                            saw_explicit_bq = true;
2060                            i += 1;
2061                        } else if saw_explicit_bq
2062                            && !lines[i].trim().is_empty()
2063                            && !lines[i].trim_start().starts_with('#')
2064                            && !lines[i].trim_start().starts_with('>')
2065                        {
2066                            // Lazy continuation of preceding blockquote
2067                            i += 1;
2068                        } else {
2069                            break;
2070                        }
2071                    }
2072                    continue;
2073                }
2074                // A list item inside the blockquote needs list-aware reflow (marker +
2075                // continuation indent); plain prose goes through the paragraph path.
2076                // A marker with nothing after it has no text to reflow; the line
2077                // below is reflowed on its own, so it stays where it was written.
2078                if opens_bare_item(ctx, i) {
2079                    i += 1;
2080                    continue;
2081                }
2082                let is_bq_list_item = ctx.lines[i]
2083                    .blockquote
2084                    .as_deref()
2085                    .is_some_and(|bq| is_list_item(&bq.content));
2086                let (warning, next_idx) = if is_bq_list_item {
2087                    self.generate_blockquote_list_item_fix(ctx, config, lines, i, line_ending, &code_span_touches)
2088                } else {
2089                    self.generate_blockquote_paragraph_fix(ctx, config, lines, i, line_ending)
2090                };
2091                if let Some(warning) = warning {
2092                    warnings.push(warning);
2093                }
2094                i = next_idx;
2095                continue;
2096            }
2097
2098            // Skip special structures (but NOT MkDocs containers - those get special handling)
2099            let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
2100                info.in_code_block
2101                    || info.in_front_matter
2102                    || info.in_html_block
2103                    || info.in_html_comment
2104                    || info.in_esm_block
2105                    || info.in_jsx_expression
2106                    || info.in_jsx_block
2107                    || info.in_mdx_comment
2108                    || info.in_mkdocstrings
2109                    || info.in_pymdown_block
2110                    || info.in_table_block
2111            });
2112
2113            // Skip link reference definitions but NOT footnote definitions.
2114            // Footnote definitions (`[^id]: prose`) contain reflowable text,
2115            // while link reference definitions (`[ref]: URL`) contain URLs
2116            // that cannot be shortened.
2117            let is_link_ref_def = may_be_link_ref_def(lines[i]);
2118
2119            // A setext heading is a heading, not a paragraph: skip every line of
2120            // its text and its underline together, the way an ATX heading is
2121            // skipped just below. Reflowing any part rewrites the document's
2122            // structure - joining the underline onto the text demotes the
2123            // heading to prose, and rewrapping the text moves words across the
2124            // heading boundary. The text spans the whole paragraph the underline
2125            // ends, so walk the flag to the end of it; the line after the last
2126            // text line is the underline.
2127            if is_setext_heading_text_line(ctx, line_num) {
2128                while i < lines.len() && is_setext_heading_text_line(ctx, i + 1) {
2129                    i += 1;
2130                }
2131                i += 1;
2132                continue;
2133            }
2134
2135            // A line of an HTML block is HTML wherever the block opens, a list
2136            // item's marker line included, and that item holds no paragraph
2137            // to reflow until the block ends.
2138            if should_skip_due_to_line_info
2139                || ctx.line_holds_html_block(i)
2140                || lines[i].trim().starts_with('#')
2141                || TableUtils::is_potential_table_row_with_flavor(lines[i], ctx.flavor)
2142                || lines[i].trim().is_empty()
2143                || is_horizontal_rule(lines[i])
2144                || is_template_directive_only(lines[i])
2145                || is_link_ref_def
2146                || ctx.line_info(line_num).is_some_and(|info| info.is_div_marker)
2147                || is_html_only_line(lines[i])
2148                || standalone_link_ends_paragraph(ctx, line_num, config)
2149            {
2150                i += 1;
2151                continue;
2152            }
2153
2154            // Handle footnote definitions: `[^id]: prose text that can be reflowed`
2155            // Supports multi-paragraph footnotes with code blocks, blockquotes,
2156            // tables, and lists preserved verbatim.
2157            // Validate structure: must start with `[^`, contain `]:`, and the ID
2158            // must not contain `[` or `]` (prevents false matches on nested brackets)
2159            if lines[i].trim().starts_with("[^") && lines[i].contains("]:") && {
2160                let after_caret = &lines[i].trim()[2..];
2161                after_caret
2162                    .find("]:")
2163                    .is_some_and(|pos| pos > 0 && !after_caret[..pos].contains(['[', ']']))
2164            } {
2165                let footnote_start = i;
2166                let line = lines[i];
2167
2168                // Extract the prefix `[^id]:`
2169                let Some(colon_pos) = line.find("]:") else {
2170                    i += 1;
2171                    continue;
2172                };
2173                let prefix_end = colon_pos + 2;
2174                let prefix = &line[..prefix_end];
2175
2176                // Content starts after `]: ` (with optional space)
2177                let content_start = if line[prefix_end..].starts_with(' ') {
2178                    prefix_end + 1
2179                } else {
2180                    prefix_end
2181                };
2182                let first_content = &line[content_start..];
2183
2184                // CommonMark footnotes use 4-space continuation indent
2185                const FN_INDENT: usize = 4;
2186
2187                // --- Line classification for footnote content ---
2188                #[derive(Debug, Clone)]
2189                enum FnLineType {
2190                    Content(String, usize),  // text, 0-based line index
2191                    Verbatim(String, usize), // preserved text, original indent
2192                    Empty,
2193                }
2194
2195                // Helper: compute visual indent (tabs = 4 spaces)
2196                let visual_indent = |s: &str| -> usize {
2197                    s.chars()
2198                        .take_while(|c| c.is_whitespace())
2199                        .map(|c| if c == '\t' { 4 } else { 1 })
2200                        .sum::<usize>()
2201                };
2202
2203                // Helper: check if a trimmed line is a fence marker (homogeneous chars)
2204                let is_fence = |s: &str| -> bool {
2205                    let t = s.trim();
2206                    let fence_char = t.chars().next();
2207                    matches!(fence_char, Some('`') | Some('~'))
2208                        && t.chars().take_while(|&c| c == fence_char.unwrap()).count() >= 3
2209                };
2210
2211                // Helper: check if a trimmed line is a setext underline
2212                let is_setext_underline = |s: &str| -> bool {
2213                    let t = s.trim();
2214                    !t.is_empty()
2215                        && (t.chars().all(|c| c == '=' || c == ' ') || t.chars().all(|c| c == '-' || c == ' '))
2216                        && t.contains(['=', '-'])
2217                };
2218
2219                // Deferred body: `[^id]:\n    content` — first line has no content,
2220                // actual content starts on the next indented line
2221                let deferred_body = first_content.trim().is_empty();
2222
2223                // Collect all lines belonging to this footnote definition
2224                let mut fn_lines: Vec<FnLineType> = Vec::new();
2225                if !deferred_body {
2226                    fn_lines.push(FnLineType::Content(first_content.to_string(), footnote_start));
2227                }
2228                let mut last_consumed = i;
2229                i += 1;
2230
2231                // Strip only the footnote continuation indent, preserving
2232                // internal indentation (e.g., code block body indent)
2233                let strip_fn_indent = |s: &str| -> String {
2234                    let mut chars = s.chars();
2235                    let mut stripped = 0;
2236                    while stripped < FN_INDENT {
2237                        match chars.next() {
2238                            Some('\t') => stripped += 4,
2239                            Some(c) if c.is_whitespace() => stripped += 1,
2240                            _ => break,
2241                        }
2242                    }
2243                    chars.as_str().to_string()
2244                };
2245
2246                let mut in_fenced_code = false;
2247                let mut consecutive_blanks = 0u32;
2248
2249                while i < lines.len() {
2250                    let next = lines[i];
2251                    let next_trimmed = next.trim();
2252
2253                    // Blank line handling
2254                    if next_trimmed.is_empty() {
2255                        consecutive_blanks += 1;
2256                        // 2+ consecutive blanks terminate the footnote
2257                        if consecutive_blanks >= 2 {
2258                            break;
2259                        }
2260
2261                        // Inside a fenced code block, blank lines are part of the code
2262                        if in_fenced_code {
2263                            consecutive_blanks = 0; // Don't count blanks inside code blocks
2264                            fn_lines.push(FnLineType::Verbatim(String::new(), 0));
2265                            last_consumed = i;
2266                            i += 1;
2267                            continue;
2268                        }
2269
2270                        // Peek ahead: if next non-blank line is indented >= FN_INDENT,
2271                        // this blank is an internal paragraph separator
2272                        if i + 1 < lines.len() {
2273                            let peek = lines[i + 1];
2274                            let peek_indent = visual_indent(peek);
2275                            if !peek.trim().is_empty() && peek_indent >= FN_INDENT {
2276                                fn_lines.push(FnLineType::Empty);
2277                                last_consumed = i;
2278                                i += 1;
2279                                continue;
2280                            }
2281                        }
2282                        // No valid continuation after blank — end of footnote
2283                        break;
2284                    }
2285
2286                    consecutive_blanks = 0;
2287                    let indent = visual_indent(next);
2288
2289                    // Not indented enough: end of footnote, unless a code span
2290                    // still open at the end of the line above runs on into this
2291                    // one, which makes it lazy text of the same paragraph.
2292                    let continues_code_span = line_ends_in_code_span(ctx, i - 1);
2293                    if indent < FN_INDENT && !continues_code_span {
2294                        break;
2295                    }
2296
2297                    // Inside a fenced code block: everything is verbatim until closing fence
2298                    if in_fenced_code {
2299                        fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2300                        if is_fence(next_trimmed) {
2301                            in_fenced_code = false;
2302                        }
2303                        last_consumed = i;
2304                        i += 1;
2305                        continue;
2306                    }
2307
2308                    // The checks below stop at what starts a block of its own.
2309                    // A code span still open at the end of the line above proves
2310                    // the parser read on into this one, so it is prose however it
2311                    // is spelled.
2312                    if !continues_code_span {
2313                        // Fence opener — start verbatim code block
2314                        if is_fence(next_trimmed) {
2315                            in_fenced_code = true;
2316                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2317                            last_consumed = i;
2318                            i += 1;
2319                            continue;
2320                        }
2321
2322                        // A multi-line display-math block is verbatim: its line breaks
2323                        // carry meaning (see `line_in_multiline_math_block`).
2324                        if self.line_in_multiline_math_block(i + 1, ctx, config) {
2325                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2326                            last_consumed = i;
2327                            i += 1;
2328                            continue;
2329                        }
2330
2331                        // Indented code block: indent >= FN_INDENT + 4 (= 8 spaces)
2332                        if indent >= FN_INDENT + 4 {
2333                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2334                            last_consumed = i;
2335                            i += 1;
2336                            continue;
2337                        }
2338
2339                        // Structural content that must be preserved verbatim
2340                        if next_trimmed.starts_with('#')
2341                            || is_list_item(next_trimmed)
2342                            || next_trimmed.starts_with('>')
2343                            || TableUtils::is_potential_table_row_with_flavor(next_trimmed, ctx.flavor)
2344                            || is_setext_underline(next_trimmed)
2345                            || is_horizontal_rule(next_trimmed)
2346                            || crate::utils::mkdocs_footnotes::is_footnote_definition(next_trimmed)
2347                        {
2348                            // Preserve verbatim: blockquotes, tables, lists, setext
2349                            // underlines, and horizontal rules inside the footnote
2350                            if next_trimmed.starts_with('>')
2351                                || TableUtils::is_potential_table_row_with_flavor(next_trimmed, ctx.flavor)
2352                                || is_list_item(next_trimmed)
2353                                || is_setext_underline(next_trimmed)
2354                                || is_horizontal_rule(next_trimmed)
2355                            {
2356                                fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2357                                last_consumed = i;
2358                                i += 1;
2359                                continue;
2360                            }
2361                            // Headings, new footnote defs, link refs — end the footnote
2362                            break;
2363                        }
2364
2365                        // Link reference definitions inside footnotes are not reflowable
2366                        if next_trimmed.starts_with('[')
2367                            && !next_trimmed.starts_with("[^")
2368                            && next_trimmed.contains("]:")
2369                            && LINK_REF_PATTERN.is_match(next_trimmed)
2370                        {
2371                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2372                            last_consumed = i;
2373                            i += 1;
2374                            continue;
2375                        }
2376
2377                        // HTML-only lines inside footnotes are not reflowable
2378                        if is_html_only_line(next_trimmed) {
2379                            fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2380                            last_consumed = i;
2381                            i += 1;
2382                            continue;
2383                        }
2384                    }
2385
2386                    // Regular prose content. A code span crossing into the line
2387                    // keeps the indentation past the footnote's own, which leaves
2388                    // the footnote as written; a lazy line has none taken off. A
2389                    // hard break at the end stays, so the paragraph keeps it.
2390                    let text = if !ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[i].byte_offset) {
2391                        trim_preserving_hard_break(next.trim_start())
2392                    } else if indent >= FN_INDENT {
2393                        trim_preserving_hard_break(&strip_fn_indent(next))
2394                    } else {
2395                        trim_preserving_hard_break(next)
2396                    };
2397                    fn_lines.push(FnLineType::Content(restore_code_span_line_end(ctx, i, text), i));
2398                    last_consumed = i;
2399                    i += 1;
2400                }
2401
2402                // Nothing collected or only empty lines
2403                if fn_lines.iter().all(|l| matches!(l, FnLineType::Empty)) || fn_lines.is_empty() {
2404                    continue;
2405                }
2406
2407                // The footnote is rebuilt with a fixed indent and its paragraphs
2408                // joined, which a definition list inside it does not survive.
2409                if holds_definition_list(ctx, footnote_start, last_consumed) {
2410                    continue;
2411                }
2412                // This collector trims prose line ends. Leave hard-break footnotes
2413                // intact rather than losing their break markers while packing.
2414                if config.reflow_mode == ReflowMode::SentencePack
2415                    && lines[footnote_start..=last_consumed]
2416                        .iter()
2417                        .any(|line| has_hard_break(line))
2418                {
2419                    continue;
2420                }
2421
2422                // --- Group into blocks ---
2423                #[derive(Debug)]
2424                enum FnBlock {
2425                    Paragraph(Vec<(String, usize)>), // (text, 1-based line number)
2426                    Verbatim(Vec<(String, usize)>),  // (content, indent) preserved as-is
2427                }
2428
2429                // Each block carries whether a blank line comes before it in the
2430                // source. Only those get one in the rebuild: a line right after a
2431                // paragraph that cannot interrupt it (`7. b`, `[a]: b`, `<span>`)
2432                // is text of that paragraph, which a blank line would cut off.
2433                let mut blocks: Vec<(bool, FnBlock)> = Vec::new();
2434                let mut current_para: Vec<(String, usize)> = Vec::new();
2435                let mut current_verbatim: Vec<(String, usize)> = Vec::new();
2436                let mut blank_before = false;
2437
2438                for fl in &fn_lines {
2439                    match fl {
2440                        FnLineType::Content(s, idx) => {
2441                            if !current_verbatim.is_empty() {
2442                                let verbatim = std::mem::take(&mut current_verbatim);
2443                                blocks.push((std::mem::take(&mut blank_before), FnBlock::Verbatim(verbatim)));
2444                            }
2445                            current_para.push((s.clone(), idx + 1));
2446                        }
2447                        FnLineType::Verbatim(s, indent) => {
2448                            if !current_para.is_empty() {
2449                                let para = std::mem::take(&mut current_para);
2450                                blocks.push((std::mem::take(&mut blank_before), FnBlock::Paragraph(para)));
2451                            }
2452                            current_verbatim.push((s.clone(), *indent));
2453                        }
2454                        FnLineType::Empty => {
2455                            if !current_para.is_empty() {
2456                                let para = std::mem::take(&mut current_para);
2457                                blocks.push((blank_before, FnBlock::Paragraph(para)));
2458                            }
2459                            if !current_verbatim.is_empty() {
2460                                let verbatim = std::mem::take(&mut current_verbatim);
2461                                blocks.push((blank_before, FnBlock::Verbatim(verbatim)));
2462                            }
2463                            blank_before = true;
2464                        }
2465                    }
2466                }
2467                if !current_para.is_empty() {
2468                    blocks.push((blank_before, FnBlock::Paragraph(current_para)));
2469                }
2470                if !current_verbatim.is_empty() {
2471                    blocks.push((blank_before, FnBlock::Verbatim(current_verbatim)));
2472                }
2473                let span_runs_into_indentation = |block: &FnBlock| match block {
2474                    FnBlock::Paragraph(lines) => {
2475                        code_span_runs_into_indentation(&lines.iter().map(|(text, _)| text).collect::<Vec<_>>())
2476                    }
2477                    FnBlock::Verbatim(_) => false,
2478                };
2479                if blocks.iter().any(|(_, block)| span_runs_into_indentation(block)) {
2480                    continue;
2481                }
2482
2483                // --- Reflow paragraphs and reconstruct ---
2484                let prefix_display_width = prefix.chars().count() + 1; // +1 for space
2485                let reflow_line_length = if config.line_length.is_unlimited() {
2486                    usize::MAX
2487                } else if config.reflow_mode == ReflowMode::SentencePack {
2488                    config.line_length.get().saturating_sub(FN_INDENT).max(1)
2489                } else {
2490                    config
2491                        .line_length
2492                        .get()
2493                        .saturating_sub(FN_INDENT.max(prefix_display_width))
2494                        .max(20)
2495                };
2496                // Footnote continuation uses a fixed 4-space indent, so list
2497                // continuation capping does not apply here.
2498                let mut reflow_options = crate::utils::text_reflow::ReflowOptions {
2499                    max_list_continuation_indent: None,
2500                    ..Self::reflow_options(ctx, config, reflow_line_length)
2501                };
2502
2503                let indent_str = " ".repeat(FN_INDENT);
2504                let mut result_lines: Vec<String> = Vec::new();
2505                let mut is_first_block = true;
2506
2507                for (blank_before, block) in &blocks {
2508                    match block {
2509                        FnBlock::Paragraph(para_lines) => {
2510                            // A hard break ends a line of the paragraph wherever
2511                            // reflow puts it, so the text between two of them is
2512                            // reflowed on its own and the break follows its last
2513                            // line. Trailing spaces a code span carries across the
2514                            // line break are code, not a hard break.
2515                            let ends_with_hard_break = |line: &str, line_num: usize| {
2516                                has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
2517                            };
2518                            let mut reflowed: Vec<String> = Vec::new();
2519                            for (segment_idx, segment) in split_into_segments(para_lines, ends_with_hard_break)
2520                                .into_iter()
2521                                .enumerate()
2522                            {
2523                                let break_marker = segment.last().and_then(|(line, line_num)| {
2524                                    if !ends_with_hard_break(line, *line_num) {
2525                                        None
2526                                    } else if line.ends_with('\\') {
2527                                        Some("\\")
2528                                    } else {
2529                                        Some("  ")
2530                                    }
2531                                });
2532                                let texts: Vec<&str> = segment
2533                                    .iter()
2534                                    .map(|(line, line_num)| {
2535                                        if ends_with_hard_break(line, *line_num) {
2536                                            line.strip_suffix('\\').unwrap_or(line).trim_end_matches([' ', '\t'])
2537                                        } else {
2538                                            line.as_str()
2539                                        }
2540                                    })
2541                                    .collect();
2542                                let segment_text = Self::join_paragraph_lines(&texts, config);
2543                                let segment_text = trim_breakable_whitespace(&segment_text);
2544                                if segment_text.is_empty() {
2545                                    continue;
2546                                }
2547                                reflow_options.first_line_length = if config.reflow_mode == ReflowMode::SentencePack
2548                                    && is_first_block
2549                                    && segment_idx == 0
2550                                    && !config.line_length.is_unlimited()
2551                                {
2552                                    Some(
2553                                        config
2554                                            .line_length
2555                                            .effective_limit()
2556                                            .saturating_sub(self.calculate_string_length(prefix) + 1)
2557                                            .max(1),
2558                                    )
2559                                } else {
2560                                    None
2561                                };
2562                                reflowed.extend(crate::utils::text_reflow::reflow_line(segment_text, &reflow_options));
2563                                if let Some(break_marker) = break_marker
2564                                    && let Some(last) = reflowed.last_mut()
2565                                {
2566                                    last.push_str(break_marker);
2567                                }
2568                            }
2569                            if reflowed.is_empty() {
2570                                continue;
2571                            }
2572
2573                            if *blank_before && !result_lines.is_empty() {
2574                                result_lines.push(String::new());
2575                            }
2576
2577                            for (idx, rline) in reflowed.iter().enumerate() {
2578                                if is_first_block && idx == 0 {
2579                                    result_lines.push(format!("{prefix} {rline}"));
2580                                } else {
2581                                    result_lines.push(format!("{indent_str}{rline}"));
2582                                }
2583                            }
2584                            is_first_block = false;
2585                        }
2586                        FnBlock::Verbatim(verb_lines) => {
2587                            if *blank_before && !result_lines.is_empty() {
2588                                result_lines.push(String::new());
2589                            }
2590
2591                            if is_first_block {
2592                                // Verbatim as first block in a deferred-body footnote
2593                                if deferred_body {
2594                                    result_lines.push(prefix.to_string());
2595                                }
2596                                is_first_block = false;
2597                            }
2598                            for (content, _orig_indent) in verb_lines {
2599                                result_lines.push(format!("{indent_str}{content}"));
2600                            }
2601                        }
2602                    }
2603                }
2604
2605                // If nothing was produced, skip
2606                if result_lines.is_empty() {
2607                    continue;
2608                }
2609
2610                let reflowed_text = result_lines.join(line_ending);
2611
2612                // Calculate byte range using last_consumed
2613                let start_range = ctx.whole_line_byte_range(footnote_start + 1);
2614                let end_range = if last_consumed == lines.len() - 1 && !ctx.content.ends_with('\n') {
2615                    ctx.line_text_byte_range(last_consumed + 1, 1, lines[last_consumed].len() + 1)
2616                } else {
2617                    ctx.whole_line_byte_range(last_consumed + 1)
2618                };
2619                let byte_range = start_range.start..end_range.end;
2620
2621                let replacement = if last_consumed < lines.len() - 1 || ctx.content.ends_with('\n') {
2622                    format!("{reflowed_text}{line_ending}")
2623                } else {
2624                    reflowed_text
2625                };
2626
2627                let original_text = &ctx.content[byte_range.clone()];
2628                let max_length = (footnote_start..=last_consumed)
2629                    .map(|idx| self.calculate_effective_length(lines[idx]))
2630                    .max()
2631                    .unwrap_or(0);
2632                let line_limit = if config.line_length.is_unlimited() {
2633                    usize::MAX
2634                } else {
2635                    config.line_length.get()
2636                };
2637                if original_text != replacement
2638                    && (matches!(
2639                        config.reflow_mode,
2640                        ReflowMode::SentencePerLine | ReflowMode::SentencePack | ReflowMode::SemanticLineBreaks
2641                    ) || max_length > line_limit)
2642                {
2643                    warnings.push(LintWarning {
2644                        rule_name: Some(self.name().to_string()),
2645                        message: if config.reflow_mode == ReflowMode::SentencePack {
2646                            "Footnote should pack complete sentences".to_string()
2647                        } else if config.reflow_mode == ReflowMode::SentencePerLine {
2648                            "Footnote should have one sentence per line".to_string()
2649                        } else if config.reflow_mode == ReflowMode::SemanticLineBreaks {
2650                            "Footnote should use semantic line breaks".to_string()
2651                        } else {
2652                            format!(
2653                                "Line length {} exceeds {} characters",
2654                                max_length,
2655                                config.line_length.get()
2656                            )
2657                        },
2658                        line: footnote_start + 1,
2659                        column: 1,
2660                        end_line: last_consumed + 1,
2661                        end_column: lines[last_consumed].chars().count() + 1,
2662                        severity: Severity::Warning,
2663                        fix: Some(crate::rule::Fix::new(byte_range, replacement)),
2664                    });
2665                }
2666                continue;
2667            }
2668
2669            // Handle MkDocs container content (admonitions and tabs) with indent-preserving reflow
2670            if ctx
2671                .line_info(line_num)
2672                .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
2673            {
2674                // Skip admonition/tab marker lines — only reflow their indented content
2675                let current_line = lines[i];
2676                if mkdocs_admonitions::is_admonition_start(current_line) || mkdocs_tabs::is_tab_marker(current_line) {
2677                    i += 1;
2678                    continue;
2679                }
2680
2681                let container_start = i;
2682
2683                // Detect the actual indent level from the first content line
2684                // (supports nested admonitions with 8+ spaces). Python-Markdown
2685                // expands a tab to the next multiple of four columns, so the indent
2686                // is measured in columns and re-emitted as that many spaces.
2687                let first_line = lines[i];
2688                let base_prefix = &first_line[..first_line.len() - first_line.trim_start_matches([' ', '\t']).len()];
2689                let base_indent_len = column_at(first_line, base_prefix.len());
2690                let base_indent: String = " ".repeat(base_indent_len);
2691
2692                // Collect consecutive MkDocs container paragraph lines
2693                let mut container_lines: Vec<&str> = Vec::new();
2694                while i < lines.len() {
2695                    let current_line_num = i + 1;
2696                    let line_info = ctx.line_info(current_line_num);
2697
2698                    // Stop if we leave the MkDocs container
2699                    if !line_info.is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container) {
2700                        break;
2701                    }
2702
2703                    let line = lines[i];
2704
2705                    // Stop at paragraph boundaries within the container
2706                    if line.trim().is_empty() {
2707                        break;
2708                    }
2709
2710                    // Skip list items, code blocks, headings, HTML-only lines within containers
2711                    if is_list_item(line.trim())
2712                        || line.trim().starts_with("```")
2713                        || line.trim().starts_with("~~~")
2714                        || line.trim().starts_with('#')
2715                        || is_html_only_line(line)
2716                    {
2717                        break;
2718                    }
2719
2720                    container_lines.push(line);
2721                    i += 1;
2722                }
2723
2724                if container_lines.is_empty() {
2725                    // Must advance i to avoid infinite loop when we encounter
2726                    // non-paragraph content (code block, list, heading, empty line)
2727                    // at the start of an MkDocs container
2728                    i += 1;
2729                    continue;
2730                }
2731
2732                // Strip the base indent from each line and join for reflow
2733                let stripped_lines: Vec<&str> = container_lines
2734                    .iter()
2735                    .map(|line| {
2736                        if let Some(rest) = line.strip_prefix(base_prefix) {
2737                            rest
2738                        } else {
2739                            line.trim_start()
2740                        }
2741                    })
2742                    .collect();
2743                if code_span_runs_into_indentation(&stripped_lines) {
2744                    continue;
2745                }
2746                let paragraph_text = Self::join_paragraph_lines(&stripped_lines, config);
2747
2748                // Check if reflow is needed
2749                let needs_reflow = match config.reflow_mode {
2750                    ReflowMode::SentencePack => true,
2751                    ReflowMode::Normalize => self.normalize_mode_needs_reflow(container_lines.iter().copied(), config),
2752                    ReflowMode::SentencePerLine => {
2753                        let sentences = split_into_sentences(
2754                            &paragraph_text,
2755                            Some(&defined_references),
2756                            config.require_sentence_capital,
2757                            config.cjk_soft_break,
2758                        );
2759                        sentences.len() > 1 || container_lines.len() > 1
2760                    }
2761                    ReflowMode::SemanticLineBreaks => {
2762                        let sentences = split_into_sentences(
2763                            &paragraph_text,
2764                            Some(&defined_references),
2765                            config.require_sentence_capital,
2766                            config.cjk_soft_break,
2767                        );
2768                        sentences.len() > 1
2769                            || container_lines.len() > 1
2770                            || container_lines
2771                                .iter()
2772                                .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2773                    }
2774                    ReflowMode::Default => container_lines
2775                        .iter()
2776                        .any(|line| self.calculate_effective_length(line) > config.line_length.get()),
2777                };
2778
2779                if !needs_reflow {
2780                    continue;
2781                }
2782
2783                // Calculate byte range for this container paragraph
2784                let start_range = ctx.whole_line_byte_range(container_start + 1);
2785                let end_line = container_start + container_lines.len() - 1;
2786                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2787                    ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
2788                } else {
2789                    ctx.whole_line_byte_range(end_line + 1)
2790                };
2791                let byte_range = start_range.start..end_range.end;
2792
2793                // Reflow with adjusted line length (accounting for the 4-space indent)
2794                let reflow_line_length = if config.line_length.is_unlimited() {
2795                    usize::MAX
2796                } else {
2797                    config.line_length.get().saturating_sub(base_indent_len).max(1)
2798                };
2799                let reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
2800                let reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
2801
2802                // Re-add the 4-space indent to each reflowed line
2803                let reflowed_with_indent: Vec<String> =
2804                    reflowed.iter().map(|line| format!("{base_indent}{line}")).collect();
2805                let reflowed_text = reflowed_with_indent.join(line_ending);
2806
2807                // Preserve trailing newline
2808                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2809                    format!("{reflowed_text}{line_ending}")
2810                } else {
2811                    reflowed_text
2812                };
2813
2814                // Only generate a warning if the replacement is different
2815                let original_text = &ctx.content[byte_range.clone()];
2816                if original_text != replacement {
2817                    warnings.push(LintWarning {
2818                        rule_name: Some(self.name().to_string()),
2819                        message: if config.reflow_mode == ReflowMode::SentencePack {
2820                            "Paragraph should pack complete sentences".to_string()
2821                        } else {
2822                            format!(
2823                                "Line length {} exceeds {} characters (in MkDocs container)",
2824                                container_lines.iter().map(|l| l.len()).max().unwrap_or(0),
2825                                config.line_length.get()
2826                            )
2827                        },
2828                        line: container_start + 1,
2829                        column: 1,
2830                        end_line: end_line + 1,
2831                        end_column: lines[end_line].chars().count() + 1,
2832                        severity: Severity::Warning,
2833                        fix: Some(crate::rule::Fix::new(byte_range, replacement)),
2834                    });
2835                }
2836                continue;
2837            }
2838
2839            // Helper function to detect semantic line markers
2840            let is_semantic_line = |content: &str| -> bool {
2841                let trimmed = content.trim_start();
2842                let semantic_markers = [
2843                    "NOTE:",
2844                    "WARNING:",
2845                    "IMPORTANT:",
2846                    "CAUTION:",
2847                    "TIP:",
2848                    "DANGER:",
2849                    "HINT:",
2850                    "INFO:",
2851                ];
2852                semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
2853            };
2854
2855            // Helper function to detect fence markers (opening or closing)
2856            let is_fence_marker = |content: &str| -> bool {
2857                let trimmed = content.trim_start();
2858                trimmed.starts_with("```") || trimmed.starts_with("~~~")
2859            };
2860
2861            // A list item whose marker line holds nothing but the marker has no
2862            // text to reflow there. The line after it is the item's content only
2863            // when indented to the content column; otherwise the item is empty
2864            // and that line starts a block of its own. Either way it is reflowed
2865            // on its own, and never joined onto the marker.
2866            let trimmed = lines[i].trim();
2867            if opens_bare_item(ctx, i) {
2868                i += 1;
2869                continue;
2870            }
2871
2872            // Check if this is a list item - handle it specially
2873            if is_list_item(trimmed) {
2874                // Collect the entire list item including continuation lines
2875                let list_start = i;
2876                let (marker, first_content) = extract_list_marker_and_content(lines[i]);
2877                let marker_len = marker.len();
2878                // The normalized marker above is what gets re-emitted; the source marker
2879                // is where the item's content actually starts. Nested blocks move by the
2880                // difference between the two content columns, so the shift must be
2881                // measured against the source, not against the normalized width.
2882                let source_marker = source_list_marker(lines[i]);
2883                let source_content_col = source_marker.as_ref().map_or(marker_len, |m| m.content_col);
2884                // Where the parser puts the item's content, which is what a
2885                // continuation line's indentation is measured against.
2886                let item_content_col = ctx.lines[i]
2887                    .list_item
2888                    .as_ref()
2889                    .map_or(source_content_col, |item| item.content_column);
2890                let first_content = restore_code_span_line_end(ctx, i, first_content);
2891
2892                // Checkbox ([ ]/[x]/[X]) is inline content, not part of the list marker.
2893                // Use the base bullet/number marker width for continuation recognition
2894                // so that continuation lines at 2+ spaces are collected for "- [ ] " items.
2895                let base_marker_len = if marker.contains("[ ] ") || marker.contains("[x] ") || marker.contains("[X] ") {
2896                    marker.find('[').unwrap_or(marker_len)
2897                } else {
2898                    marker_len
2899                };
2900
2901                // MkDocs flavor requires at least 4 spaces for list continuation
2902                // after a blank line (multi-paragraph list items). For non-blank
2903                // continuation (lines directly following the marker line), use
2904                // the natural marker width so that 2-space indent is recognized.
2905                //
2906                // The marker above keeps one padding space, and the item's content
2907                // starts past all of it, so a line is measured against the column
2908                // the padding as written reaches. A line short of that column
2909                // after a blank line is not in the item.
2910                let item_indent = ctx.lines[i].indent;
2911                let source_padding = item_content_col.saturating_sub(base_marker_len);
2912                // The thresholds above are byte offsets into the marker line. A
2913                // tab there reaches further than its one byte, so a line belongs
2914                // to the item only when its indentation reaches as many columns
2915                // as the content does. The bytes must reach the content as well:
2916                // lines are stripped and re-emitted by bytes, so a tab-indented
2917                // line that reaches it only by width is left to the outer loop,
2918                // which keeps it as written.
2919                let tab_widening = ctx.lines[i]
2920                    .list_item
2921                    .as_ref()
2922                    .map_or(0, |item| column_at(lines[i], item.content_column) - item.content_column);
2923                let min_continuation_indent = if ctx.flavor.requires_strict_list_indent() {
2924                    // Use 4-space relative indent from the list item's nesting level
2925                    item_indent + (base_marker_len - item_indent).max(4)
2926                } else {
2927                    marker_len + source_padding
2928                };
2929                let content_continuation_indent = base_marker_len + source_padding;
2930                let min_continuation_column = min_continuation_indent + tab_widening;
2931                let content_continuation_column = content_continuation_indent + tab_widening;
2932
2933                // Track lines and their types (content, code block, fence, nested list)
2934                #[derive(Clone)]
2935                enum LineType {
2936                    Content(String, usize),           // content and 1-indexed line number
2937                    CodeBlock(String, usize),         // content and original indent
2938                    SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
2939                    SnippetLine(String),  // MkDocs Snippets delimiters (-8<-) that must stay on their own line
2940                    DivMarker(String),    // Quarto/Pandoc div markers (::: opening or closing)
2941                    AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
2942                    AdmonitionContent(String, usize), // body content text and original indent
2943                    Table(String, usize), // GFM table row, preserved verbatim with original indent
2944                    Empty,
2945                }
2946
2947                let start_idx = i;
2948                // A marker line holding a second marker opens an item of its own,
2949                // and the line info describes only the outer one. The inner
2950                // item's content column is the indentation a code span crossing
2951                // into a continuation line leaves out, so it cannot be told here.
2952                let opens_nested_item = is_list_item(first_content.trim_start());
2953                // A marker line whose content is one whole `$$...$$` expression
2954                // renders as a display block, so it keeps the line it was written
2955                // on and the prose under it starts a paragraph of its own. The
2956                // code-block carrier re-emits it unchanged after the marker.
2957                //
2958                // The marker keeps one padding space and leaves the rest at the
2959                // head of the content, and the carrier writes the marker as the
2960                // author spelled it, padding included. The carrier therefore
2961                // holds the content with its leading whitespace off, so the
2962                // padding is written once.
2963                //
2964                // A marker that cannot interrupt a paragraph, such as an
2965                // ordered one not starting at one, can sit inside a code span
2966                // the paragraph above opened, and its content is code then.
2967                // The marker line can just as well be the one that opens the
2968                // span, closing on a line still to come.
2969                let mut list_item_lines: Vec<LineType> = if (is_self_contained_display_math_line(&first_content)
2970                    || self.line_is_standalone_bracket_math(i + 1, ctx, config))
2971                    && !line_touches_multiline_code_span(&code_span_touches, i + 1)
2972                {
2973                    vec![LineType::CodeBlock(first_content.trim_start().to_string(), marker_len)]
2974                } else if marker_len == base_marker_len && marker_line_opens_non_paragraph(&first_content) {
2975                    // A heading, thematic break or reference definition on the
2976                    // marker line is written back as it is, and the prose under
2977                    // it is a paragraph of its own. After a task checkbox the
2978                    // text is inline, so none of them can open there.
2979                    vec![LineType::CodeBlock(first_content.trim_start().to_string(), marker_len)]
2980                } else {
2981                    vec![LineType::Content(first_content, i + 1)]
2982                };
2983                i += 1;
2984                let mut leave_as_written = false;
2985
2986                // Collect continuation lines using ctx.lines for metadata
2987                while i < lines.len() {
2988                    let line_info = &ctx.lines[i];
2989
2990                    // An invalid fence can be a lazy continuation of this
2991                    // paragraph. Re-indenting or joining it changes how GFM
2992                    // renderers recognize neighboring bare autolinks. Keep
2993                    // the item as written unless the line continues a code span.
2994                    if !line_info.in_code_block
2995                        && line_info.visual_indent < content_continuation_column
2996                        && is_fence_marker(lines[i])
2997                        && !line_ends_in_code_span(ctx, i - 1)
2998                    {
2999                        leave_as_written = true;
3000                    }
3001
3002                    // Use pre-computed is_blank from ctx
3003                    if line_info.is_blank {
3004                        // Empty line - check if next line is indented (part of list item)
3005                        if i + 1 < lines.len() {
3006                            let next_info = &ctx.lines[i + 1];
3007
3008                            // Check if next line is indented enough to be continuation
3009                            if !next_info.is_blank
3010                                && next_info.indent >= min_continuation_indent
3011                                && next_info.visual_indent >= min_continuation_column
3012                            {
3013                                // This blank line is between paragraphs/blocks in the list item
3014                                list_item_lines.push(LineType::Empty);
3015                                i += 1;
3016                                continue;
3017                            }
3018                        }
3019                        // No indented line after blank, end of list item
3020                        break;
3021                    }
3022
3023                    // Use pre-computed indent from ctx
3024                    let indent = line_info.indent;
3025
3026                    // Valid continuation must be indented at least content_continuation_indent.
3027                    // For non-blank continuation, use marker_len (e.g. 2 for "- ").
3028                    // MkDocs strict 4-space requirement applies only after blank lines.
3029                    if indent >= content_continuation_indent && line_info.visual_indent >= content_continuation_column {
3030                        // A line kept on its own is re-indented with spaces, and
3031                        // implementations disagree on how much of a tab the
3032                        // item's indentation consumes, so an item holding a line
3033                        // indented with a tab is left as written.
3034                        leave_as_written |= line_info.content(ctx.content)[..indent].contains('\t');
3035                        let trimmed = line_info.content(ctx.content).trim();
3036
3037                        // Check for MkDocs admonition lines inside list items BEFORE
3038                        // checking in_code_block. Lines inside code blocks within
3039                        // admonitions have both in_admonition and in_code_block set;
3040                        // admonition membership takes priority so the entire admonition
3041                        // structure (including embedded code blocks) is preserved.
3042                        if line_info.in_admonition {
3043                            let raw_content = line_info.content(ctx.content);
3044                            if mkdocs_admonitions::is_admonition_start(raw_content) {
3045                                let header_text = raw_content[indent..].trim_end_matches([' ', '\t']).to_string();
3046                                list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
3047                            } else {
3048                                let body_text = raw_content[indent..].trim_end_matches([' ', '\t']).to_string();
3049                                list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
3050                            }
3051                            i += 1;
3052                            continue;
3053                        }
3054
3055                        // Use pre-computed in_code_block from ctx
3056                        if line_info.in_code_block {
3057                            list_item_lines.push(LineType::CodeBlock(
3058                                line_info.content(ctx.content)[indent..].to_string(),
3059                                indent,
3060                            ));
3061                            i += 1;
3062                            continue;
3063                        }
3064
3065                        // A multi-line display-math block inside the item is verbatim:
3066                        // its line breaks carry meaning (see
3067                        // `line_in_multiline_math_block`), so reuse the code-block
3068                        // carrier to re-emit it unchanged. A reader without math
3069                        // can see a code span crossing into this line from the
3070                        // prose above, and reflowing that prose on its own would
3071                        // rewrite the whitespace the span holds.
3072                        if self.line_in_multiline_math_block(i + 1, ctx, config) {
3073                            leave_as_written |= line_ends_in_code_span(ctx, i - 1);
3074                            list_item_lines.push(LineType::CodeBlock(
3075                                line_info.content(ctx.content)[indent..].to_string(),
3076                                indent,
3077                            ));
3078                            i += 1;
3079                            continue;
3080                        }
3081
3082                        // A blockquote nested inside the list item is reflowed by the
3083                        // blockquote-aware path (it preserves the `>` prefix, including the
3084                        // list indent), not as list-item prose. Collecting it as Content
3085                        // would strip the markers and reflow `>` as words, collapsing the
3086                        // blank `>` line and dropping `>` from wrapped continuations. End
3087                        // the item here so the outer loop routes the blockquote line to
3088                        // generate_blockquote_paragraph_fix. Uncollect a pending blank so
3089                        // the separator between the list prose and the blockquote survives.
3090                        if line_info.blockquote.is_some() {
3091                            if matches!(list_item_lines.last(), Some(LineType::Empty)) {
3092                                list_item_lines.pop();
3093                                i -= 1;
3094                            }
3095                            break;
3096                        }
3097
3098                        // Check if this is a SIBLING list item (breaks parent)
3099                        // Nested lists are indented >= marker_len and are PART of the parent item
3100                        // Siblings are at indent < marker_len (at or before parent marker)
3101                        // The parser also recognizes an item whose marker line holds
3102                        // nothing else, which `is_list_item` does not. A line a code
3103                        // span runs on into continues the paragraph that span is in,
3104                        // whatever marker it starts with.
3105                        let starts_item = !line_ends_in_code_span(ctx, i - 1)
3106                            && (is_list_item(trimmed) || line_info.list_item.is_some());
3107                        if starts_item && indent < marker_len {
3108                            // This is a sibling item at same or higher level - end parent item
3109                            break;
3110                        }
3111
3112                        // Nested list items are always processed independently
3113                        // by the outer loop, so break when we encounter one.
3114                        // If a blank line was collected before this, uncollect it
3115                        // so the outer loop preserves the blank between parent and nested.
3116                        if starts_item && indent >= marker_len {
3117                            if matches!(list_item_lines.last(), Some(LineType::Empty)) {
3118                                list_item_lines.pop();
3119                                i -= 1;
3120                            }
3121                            break;
3122                        }
3123
3124                        // Normal continuation vs indented code block.
3125                        // Use min_continuation_indent for the threshold since
3126                        // code blocks start 4 spaces beyond the expected content
3127                        // level (which is min_continuation_indent for MkDocs).
3128                        // An indented code block cannot interrupt a paragraph, so
3129                        // a line under the item's prose continues that paragraph
3130                        // however deep it is indented.
3131                        if indent <= min_continuation_indent + 3
3132                            || matches!(list_item_lines.last(), Some(LineType::Content(..)))
3133                        {
3134                            // Extract content (remove indentation and trailing whitespace)
3135                            // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
3136                            // See: https://github.com/rvben/rumdl/issues/76
3137                            let strip = continuation_indent_to_strip(ctx, i, indent, item_content_col);
3138                            let content = restore_code_span_line_end(
3139                                ctx,
3140                                i,
3141                                trim_preserving_hard_break(&line_info.content(ctx.content)[strip..]),
3142                            );
3143
3144                            // An underline under the item's paragraph text makes
3145                            // that text a setext heading. The parser records no
3146                            // heading for one whose text starts on the marker
3147                            // line, so the collector cannot tell its lines from
3148                            // prose, and rewrapping them would end the heading
3149                            // elsewhere or join the underline into its text.
3150                            leave_as_written |= is_setext_underline_content(&content)
3151                                && matches!(list_item_lines.last(), Some(LineType::Content(..)));
3152
3153                            // A line a code span crosses into or out of is text of
3154                            // the paragraph that span is in, whatever block its
3155                            // text alone would open. Kept on its own line, it
3156                            // would be written back past an indentation the span
3157                            // already holds part of.
3158                            if line_ends_in_code_span(ctx, i - 1) || line_ends_in_code_span(ctx, i) {
3159                                list_item_lines.push(LineType::Content(content, i + 1));
3160                            }
3161                            // Check if this is a div marker (::: opening or closing)
3162                            // These must be preserved on their own line, not merged into paragraphs
3163                            else if line_info.is_div_marker {
3164                                list_item_lines.push(LineType::DivMarker(content));
3165                            }
3166                            // A fence marker opens or closes a code block, a
3167                            // heading is a block of its own, its setext text lines
3168                            // and underline included, and a line that is one whole
3169                            // `$$...$$` expression renders as a display block.
3170                            // Each keeps the line it was written on, so the
3171                            // code-block carrier re-emits it unchanged between the
3172                            // prose above and below. A line touched by a code span
3173                            // crossing one of its boundaries is code, not such a
3174                            // block.
3175                            else if is_fence_marker(&content)
3176                                || line_info.heading.is_some()
3177                                || line_info.is_setext_heading_text
3178                                || (is_setext_underline_content(&content) && ctx.lines[i - 1].is_setext_heading_text)
3179                                || ((is_self_contained_display_math_line(&content)
3180                                    || self.line_is_standalone_bracket_math(i + 1, ctx, config))
3181                                    && !line_touches_multiline_code_span(&code_span_touches, i + 1))
3182                            {
3183                                list_item_lines.push(LineType::CodeBlock(content, indent));
3184                            }
3185                            // Check if this is a semantic line (NOTE:, WARNING:, etc.).
3186                            else if is_semantic_line(&content) {
3187                                list_item_lines.push(LineType::SemanticLine(content));
3188                            }
3189                            // Check if this is a snippet block delimiter (-8<- or --8<--)
3190                            // These must be preserved on their own lines for MkDocs Snippets extension
3191                            else if is_snippet_block_delimiter(&content) {
3192                                list_item_lines.push(LineType::SnippetLine(content));
3193                            }
3194                            // Check if this is a GFM table row. Tables nested inside list
3195                            // items must be preserved verbatim — joining them with prose
3196                            // breaks the column structure.
3197                            //
3198                            // `is_potential_table_row` is intentionally permissive at the
3199                            // row level: any line with `|` and 2+ cells qualifies. To avoid
3200                            // misclassifying prose continuation lines that contain a literal
3201                            // pipe (e.g. "use grep | sort to ..."), require one of:
3202                            //   - the row is pipe-bordered (`| ... |`), the canonical form
3203                            //     for tables nested in lists; or
3204                            //   - the next line is a delimiter row (this is a header); or
3205                            //   - the previous classified line was already a Table (this is
3206                            //     a continuation row).
3207                            //
3208                            // A row the table detection found is a row whatever it holds:
3209                            // GFM runs the rows on without pipes until a blank line or
3210                            // another block.
3211                            else if line_info.in_table_block
3212                                || TableUtils::is_potential_table_row_with_flavor(&content, ctx.flavor) && {
3213                                    let pipe_bordered =
3214                                        content.trim().starts_with('|') && content.trim().ends_with('|');
3215                                    let next_is_delim = ctx
3216                                        .lines
3217                                        .get(i + 1)
3218                                        .is_some_and(|next| TableUtils::is_delimiter_row(next.content(ctx.content)));
3219                                    let prev_was_table = matches!(list_item_lines.last(), Some(LineType::Table(..)));
3220                                    pipe_bordered || next_is_delim || prev_was_table
3221                                }
3222                            {
3223                                list_item_lines.push(LineType::Table(content, indent));
3224                            } else {
3225                                list_item_lines.push(LineType::Content(content, i + 1));
3226                            }
3227                            i += 1;
3228                        } else {
3229                            // indent >= min_continuation_indent + 4: indented code block
3230                            list_item_lines.push(LineType::CodeBlock(
3231                                line_info.content(ctx.content)[indent..].to_string(),
3232                                indent,
3233                            ));
3234                            i += 1;
3235                        }
3236                    } else if matches!(list_item_lines.last(), Some(LineType::Content(..)))
3237                        && line_info.visual_indent < content_continuation_column
3238                        && is_lazy_continuation(ctx, i)
3239                    {
3240                        // A lazy continuation line continues the item's open
3241                        // paragraph, so it is reflowed with it. Reflowed on its own at
3242                        // its source column, a wrap could start a line with a marker
3243                        // such as `2)` that opens a list there, where no paragraph of
3244                        // the matched containers is open for it to continue. Only the
3245                        // indentation of the enclosing items the line still reaches is
3246                        // stripped, so a code span running into the rest leaves the
3247                        // item as written.
3248                        //
3249                        // A lazy line that is one whole tag would open a type-7
3250                        // HTML block outside the item. Such a block cannot
3251                        // interrupt a paragraph, so CommonMark keeps the line in
3252                        // the item, but markdown-rs and comrak end the item before
3253                        // it. Indenting the line or joining it into the paragraph
3254                        // changes how one of the two readings renders, so the item
3255                        // is left as written.
3256                        leave_as_written |= opens_tag_line_html_block(line_info.content(ctx.content).trim_start());
3257                        let strip = continuation_indent_to_strip(
3258                            ctx,
3259                            i,
3260                            indent,
3261                            enclosing_item_content_col_reached(ctx, list_start, indent),
3262                        );
3263                        let content = restore_code_span_line_end(
3264                            ctx,
3265                            i,
3266                            trim_preserving_hard_break(&line_info.content(ctx.content)[strip..]),
3267                        );
3268                        list_item_lines.push(LineType::Content(content, i + 1));
3269                        i += 1;
3270                    } else {
3271                        // Not indented enough, end of list item
3272                        break;
3273                    }
3274                }
3275                // A code span crossing out of the item's last line runs on into
3276                // a line the item does not collect. Its paragraph continues past
3277                // where the item ends, and reflowing the part inside the item
3278                // would rewrite the whitespace the span holds.
3279                leave_as_written |= line_ends_in_code_span(ctx, i - 1);
3280                leave_as_written |= opens_nested_item && (start_idx..i - 1).any(|idx| line_ends_in_code_span(ctx, idx));
3281                // Each run of content lines is one paragraph of the item.
3282                //
3283                // When a tab pads a marker on the item's first line, the
3284                // readers disagree on how far past the content column each
3285                // later line sits, so a line one of them takes for a block of
3286                // its own (a quote, a heading) is paragraph text to the other.
3287                // A code span then crosses into that line in one reading
3288                // only, and reflowing the paragraph above it would rewrite
3289                // the whitespace the span holds there.
3290                let readers_split_blocks = markers_padded_with_tab(lines[start_idx]);
3291                leave_as_written |= list_item_lines
3292                    .chunk_by(|a, b| matches!((a, b), (LineType::Content(..), LineType::Content(..))))
3293                    .any(|run| {
3294                        let mut texts: Vec<&str> = run
3295                            .iter()
3296                            .filter_map(|line| match line {
3297                                LineType::Content(text, _) => Some(text.as_str()),
3298                                _ => None,
3299                            })
3300                            .collect();
3301                        if code_span_runs_into_indentation(&texts) {
3302                            return true;
3303                        }
3304                        let Some(LineType::Content(_, last_line_num)) = run.last() else {
3305                            return false;
3306                        };
3307                        let next_line = lines
3308                            .get(*last_line_num)
3309                            .map(|line| line.trim_start_matches([' ', '\t']));
3310                        readers_split_blocks
3311                            && next_line.is_some_and(|next| {
3312                                texts.push(next);
3313                                !next.is_empty() && code_span_crosses_last_break(&texts)
3314                            })
3315                    });
3316                // The item a marker line opens inside this one has a content
3317                // column of its own. A paragraph after a blank line belongs to
3318                // that inner item when it reaches the inner column and to this
3319                // item when it does not, so it is written back at the column of
3320                // the item it is in. Paragraphs of both items cannot share one
3321                // indentation, so an item holding both is left as written.
3322                // Held as the inner marker's width, which is how far the inner
3323                // column sits past this item's, and the indentation the first
3324                // such paragraph was written at.
3325                let nested_paragraph = source_marker.as_ref().filter(|_| opens_nested_item).and_then(|outer| {
3326                    let inner = source_list_marker(&lines[start_idx][outer.text.len()..])?;
3327                    let nested_col = outer.content_col + inner.content_col;
3328                    let indents: Vec<usize> = list_item_lines
3329                        .iter()
3330                        .skip_while(|line| !matches!(line, LineType::Empty))
3331                        .filter_map(|line| match line {
3332                            LineType::Content(_, line_num) => Some(ctx.lines[line_num - 1].indent),
3333                            _ => None,
3334                        })
3335                        .collect();
3336                    leave_as_written |= indents.iter().any(|&indent| indent < nested_col)
3337                        && indents.iter().any(|&indent| indent >= nested_col);
3338                    let first = *indents.first()?;
3339                    (first >= nested_col).then_some((inner.content_col, first))
3340                });
3341                if leave_as_written {
3342                    // Left as written, the item renders as its author saw it.
3343                    continue;
3344                }
3345
3346                // Determine the output continuation indent.
3347                // Normalize/Default modes canonicalize to min_continuation_indent
3348                // (fixing over-indented continuation). Semantic/SentencePerLine
3349                // modes preserve the user's actual indent since they only fix
3350                // line breaking, not indentation.
3351                let indent_size = match config.reflow_mode {
3352                    ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine | ReflowMode::SentencePack => {
3353                        // Find indent of the first plain text continuation line,
3354                        // skipping the marker line (index 0), nested list items,
3355                        // code blocks, blank lines, and lazy lines, whose indent
3356                        // falls short of the item's content. Every paragraph of
3357                        // the item is written at this indent, and one after a
3358                        // blank line indented four or more past the content is
3359                        // a code block, so a continuation line indented that
3360                        // deep sets no indent.
3361                        list_item_lines
3362                            .iter()
3363                            .skip(1)
3364                            .find_map(|lt| match lt {
3365                                LineType::Content(_, line_num)
3366                                    if (content_continuation_indent..content_continuation_column + 4)
3367                                        .contains(&ctx.lines[line_num - 1].indent) =>
3368                                {
3369                                    Some(ctx.lines[line_num - 1].indent)
3370                                }
3371                                _ => None,
3372                            })
3373                            .unwrap_or(min_continuation_indent)
3374                    }
3375                    _ => min_continuation_indent,
3376                };
3377                let indent_size = match (config.reflow_mode, nested_paragraph) {
3378                    (
3379                        ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine | ReflowMode::SentencePack,
3380                        Some((_, written)),
3381                    ) => written,
3382                    _ => indent_size,
3383                };
3384                // For checkbox items in mkdocs flavor, enforce minimum indent so
3385                // continuation lines use the structural list indent (4), not the
3386                // content-aligned indent (6) which Python-Markdown doesn't support
3387                let has_checkbox = base_marker_len < marker_len;
3388                let indent_size = if has_checkbox && ctx.flavor.requires_strict_list_indent() {
3389                    indent_size.max(min_continuation_indent)
3390                } else {
3391                    indent_size
3392                };
3393
3394                // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
3395                let mut builder = BlockBuilder::new_with_start_line(start_idx + 1);
3396                for line in &list_item_lines {
3397                    match line {
3398                        LineType::Empty => builder.feed_blank_line(),
3399                        LineType::Content(content, line_num) => {
3400                            builder.feed_content(content, ctx.lines[line_num - 1].indent)
3401                        }
3402                        LineType::CodeBlock(content, indent) => builder.feed_code_line(content, *indent),
3403                        LineType::SemanticLine(content) => builder.feed_semantic_line(content),
3404                        LineType::SnippetLine(content) => builder.feed_snippet_line(content),
3405                        LineType::DivMarker(content) => builder.feed_div_marker(content),
3406                        LineType::AdmonitionHeader(header_text, indent) => {
3407                            builder.feed_admonition_header(header_text, *indent)
3408                        }
3409                        LineType::AdmonitionContent(content, indent) => {
3410                            builder.feed_admonition_content(content, *indent)
3411                        }
3412                        LineType::Table(content, indent) => builder.feed_table_line(content, *indent),
3413                    }
3414                }
3415                let blocks = builder.finalize();
3416
3417                // Helper: check if a line (raw source or stripped content) is exempt
3418                // from line-length checks. Link reference definitions are always exempt;
3419                // standalone link/image lines are exempt when strict mode is off.
3420                // Also checks content after stripping list markers, since list item
3421                // continuation lines may contain link ref defs.
3422                let is_exempt_line = |raw_line: &str, line_num: usize| -> bool {
3423                    let trimmed = raw_line.trim();
3424                    // Link reference definitions: always exempt
3425                    if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
3426                        return true;
3427                    }
3428                    // Also check after stripping list markers (for list item content)
3429                    if is_list_item(trimmed) {
3430                        let (_, content) = extract_list_marker_and_content(trimmed);
3431                        let content_trimmed = content.trim();
3432                        if content_trimmed.starts_with('[')
3433                            && content_trimmed.contains("]:")
3434                            && LINK_REF_PATTERN.is_match(content_trimmed)
3435                        {
3436                            return true;
3437                        }
3438                    }
3439                    // Standalone link/image lines: exempt when not strict
3440                    if standalone_link_ends_paragraph(ctx, line_num, config) {
3441                        return true;
3442                    }
3443                    // HTML-only lines: exempt when not strict
3444                    if !config.strict && is_html_only_line(raw_line) {
3445                        return true;
3446                    }
3447                    false
3448                };
3449
3450                // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
3451                // Exclude link reference definitions and standalone link lines from content
3452                // so they don't pollute combined_content or trigger false reflow.
3453                let content_lines: Vec<String> = list_item_lines
3454                    .iter()
3455                    .filter_map(|line| {
3456                        if let LineType::Content(s, line_num) = line {
3457                            if is_exempt_line(s, *line_num) {
3458                                return None;
3459                            }
3460                            Some(s.clone())
3461                        } else {
3462                            None
3463                        }
3464                    })
3465                    .collect();
3466
3467                // Check if we need to reflow this list item
3468                // We check the combined content to see if it exceeds length limits
3469                let combined_content =
3470                    trim_breakable_whitespace(&Self::join_paragraph_lines(&content_lines, config)).to_string();
3471
3472                // Helper to check if we should reflow in normalize mode
3473                let should_normalize = || {
3474                    // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
3475                    // DO normalize if it has plain text content that spans multiple lines
3476                    let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
3477                    let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine { .. }));
3478                    let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine { .. }));
3479                    let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker { .. }));
3480                    let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
3481                    let has_tables = blocks.iter().any(|b| matches!(b, Block::Table { .. }));
3482                    let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph { .. }));
3483
3484                    // If we have structural blocks but no paragraphs, don't normalize
3485                    if (has_code_blocks
3486                        || has_semantic_lines
3487                        || has_snippet_lines
3488                        || has_div_markers
3489                        || has_admonitions
3490                        || has_tables)
3491                        && !has_paragraphs
3492                    {
3493                        return false;
3494                    }
3495
3496                    // If we have paragraphs, check if they span multiple lines or there are multiple blocks
3497                    if has_paragraphs {
3498                        // Count only paragraphs that contain at least one non-exempt line.
3499                        // Paragraphs consisting entirely of link ref defs or standalone links
3500                        // should not trigger normalization.
3501                        let paragraph_count = blocks
3502                            .iter()
3503                            .filter(|b| {
3504                                if let Block::Paragraph { lines: para_lines, .. } = b {
3505                                    !para_lines
3506                                        .iter()
3507                                        .all(|(line, line_num)| is_exempt_line(line, *line_num))
3508                                } else {
3509                                    false
3510                                }
3511                            })
3512                            .count();
3513                        if paragraph_count > 1 {
3514                            // Multiple non-exempt paragraph blocks should be normalized
3515                            return true;
3516                        }
3517
3518                        // Single paragraph block: normalize if it has multiple content lines
3519                        if content_lines.len() > 1 {
3520                            return true;
3521                        }
3522                    }
3523
3524                    false
3525                };
3526
3527                // Integrate MD030 list-marker spacing (and MD007's text-aligned
3528                // continuation). In Default/Normalize modes — the modes that already
3529                // canonicalize spacing/indent — derive the number of spaces after the
3530                // marker from the configured MD030 values instead of forcing a single
3531                // space, then align continuation lines to the resulting content column.
3532                // Sentence/Semantic modes only adjust line breaks, so they keep the
3533                // marker spacing and indentation already present in the source.
3534                //
3535                // Re-spacing the marker moves the item's content column, so it is only
3536                // safe when this fix rewrites every line the item owns. Nested lists,
3537                // nested blockquotes, and blocks after a tab-indented continuation are
3538                // left to the outer loop and keep their source indentation; an item that owns
3539                // any of them keeps its source marker and content column, and MD030
3540                // re-spaces it together with everything it owns.
3541                // The MkDocs flavor enforces a rigid structural indent (4 spaces,
3542                // capped via max_list_continuation_indent) that Python-Markdown
3543                // requires; leave its specialized handling untouched.
3544                //
3545                // A content column moved left can reach the first line after the
3546                // item, which stopped short of the written column and so ended
3547                // the item. That line would join the item, so the marker keeps
3548                // its spacing then.
3549                //
3550                // A tab after the marker reaches the next tab stop for
3551                // CommonMark, while markdown-rs counts it as one space of
3552                // padding. Only the marker as written keeps both readings of
3553                // where the item's lines belong, so it is kept.
3554                let rewrites_whole_item = !item_owns_lines_from(ctx, lines, list_start, i);
3555                let next_line_column = ctx.lines[i..]
3556                    .iter()
3557                    .find(|line| !line.is_blank)
3558                    .map(|line| line.visual_indent);
3559                let respaced = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
3560                    && !ctx.flavor.requires_strict_list_indent()
3561                    && rewrites_whole_item
3562                    && let Some(li) = ctx.lines[list_start].list_item.as_deref()
3563                {
3564                    let bullet_len = li.marker.len();
3565                    // The checkbox (e.g. `[ ] `) is content, not part of the list
3566                    // marker MD030 governs; carry it over verbatim after the spacing.
3567                    let checkbox_tail = marker[base_marker_len..].to_string();
3568                    let indent_prefix = &marker[..item_indent];
3569
3570                    // Decide single- vs multi-line spacing from the *rewritten* shape,
3571                    // not the source. A multi-line source is not enough: plain prose
3572                    // continuation collapses onto the marker line during reflow, so a
3573                    // two-line bullet that fits becomes a single physical line and must
3574                    // use MD030's single-line spacing (otherwise MD013 emits a result
3575                    // that MD030 immediately rewrites). The emitted item stays
3576                    // multi-line only when reflow cannot collapse it:
3577                    //   - the prose wraps past the line length, or
3578                    //   - a structural block remains (code, table, admonition, semantic
3579                    //     line, snippet, div marker, HTML) that is not joinable prose, or
3580                    //   - more than one paragraph remains (blank-separated).
3581                    // The wrap test uses the single-line content column so it is
3582                    // independent of the spacing we are about to choose (avoiding a
3583                    // circular result). `ol-align-column` ignores this flag entirely in
3584                    // expected_spaces(), so ordered lists are unaffected.
3585                    //
3586                    // This is the rewritten-shape counterpart of MD030's
3587                    // `is_multi_line_list_item` (which keys off the *source*). The two
3588                    // are related but technically distinct and intentionally separate;
3589                    // if the notion of "multi-line" changes in one, revisit the other.
3590                    let single_col = item_indent
3591                        + bullet_len
3592                        + self.list_spacing.expected_spaces(li.is_ordered, false, bullet_len)
3593                        + checkbox_tail.len();
3594                    let prose_wraps = !combined_content.is_empty()
3595                        && self.calculate_effective_length(&format!("{}{combined_content}", " ".repeat(single_col)))
3596                            > config.line_length.effective_limit();
3597                    let has_structural_block = blocks.iter().any(|b| !matches!(b, Block::Paragraph { .. }));
3598                    let multiple_paragraphs =
3599                        blocks.iter().filter(|b| matches!(b, Block::Paragraph { .. })).count() > 1;
3600                    let is_multi = prose_wraps || has_structural_block || multiple_paragraphs;
3601
3602                    let spaces = self.list_spacing.expected_spaces(li.is_ordered, is_multi, bullet_len);
3603                    let new_marker = format!("{indent_prefix}{}{}{checkbox_tail}", li.marker, " ".repeat(spaces));
3604                    let new_col = new_marker.chars().count();
3605                    let shift = new_col as isize - source_content_col as isize;
3606                    Some((new_marker, new_col, shift))
3607                } else {
3608                    None
3609                }
3610                .filter(|(_, new_col, _)| {
3611                    tab_widening == 0
3612                        && next_line_column.is_none_or(|column| !(*new_col..source_content_col).contains(&column))
3613                });
3614                let (marker, indent_size, code_indent_shift) = respaced.unwrap_or_else(|| {
3615                    // The item is emitted exactly as written and the lines it owns
3616                    // never move. Re-emitting the normalized marker here would narrow
3617                    // the content column while leaving those lines behind. MkDocs
3618                    // keeps its structural continuation indent; elsewhere a line
3619                    // stays inside the item at the column the parser gives it.
3620                    let marker = source_marker.map_or(marker, |m| m.text);
3621                    let indent_size = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
3622                        && !ctx.flavor.requires_strict_list_indent()
3623                    {
3624                        item_content_col + tab_widening
3625                    } else {
3626                        indent_size
3627                    };
3628                    (marker, indent_size, 0isize)
3629                });
3630                let indent_size = match (config.reflow_mode, nested_paragraph) {
3631                    (ReflowMode::Default | ReflowMode::Normalize, Some((inner_marker_width, _))) => {
3632                        indent_size + inner_marker_width
3633                    }
3634                    _ => indent_size,
3635                };
3636                let expected_indent = " ".repeat(indent_size);
3637
3638                // A colon-led line with a line of its paragraph before it opens a
3639                // definition and makes the item a definition list. Joining the
3640                // lines flattens that into prose, so the item is left as the
3641                // author wrote it. The first line of each paragraph is prose
3642                // whatever it starts with, since a definition needs a term on
3643                // the line before it. Content lines arrive with the item's own
3644                // indentation already off, which is what the marker's
3645                // indentation is counted from.
3646                let contains_definition_list = blocks.iter().any(|block| match block {
3647                    Block::Paragraph { lines: para_lines, .. } => para_lines
3648                        .iter()
3649                        .skip(1)
3650                        .any(|(line, _)| crate::utils::text_reflow::is_definition_list_marker(line)),
3651                    _ => false,
3652                });
3653
3654                // Whether a run of prose lines between hard breaks, written as one
3655                // line, stays within the line length. The run is measured with
3656                // the prefix and the hard break it is emitted with: the marker
3657                // for the first run of the item, the continuation indent for the
3658                // others.
3659                let run_fits_on_one_line = |segment: &[(String, usize)], starts_item: bool| -> bool {
3660                    if config.line_length.is_unlimited() {
3661                        return true;
3662                    }
3663                    let ends_with_hard_break = |line: &str, line_num: usize| {
3664                        has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3665                    };
3666                    let mut hard_break_width = 0;
3667                    let segment_lines: Vec<String> = segment
3668                        .iter()
3669                        .map(|(line, line_num)| {
3670                            let line = line.strip_suffix('\r').unwrap_or(line);
3671                            if !ends_with_hard_break(line, *line_num) {
3672                                line.to_string()
3673                            } else if let Some(text) = line.strip_suffix('\\') {
3674                                hard_break_width = 1;
3675                                text.trim_end_matches([' ', '\t']).to_string()
3676                            } else if let Some(text) = line.strip_suffix("  ") {
3677                                hard_break_width = 2;
3678                                text.trim_end_matches([' ', '\t']).to_string()
3679                            } else {
3680                                line.to_string()
3681                            }
3682                        })
3683                        .collect();
3684                    let joined =
3685                        trim_breakable_whitespace(&Self::join_paragraph_lines(&segment_lines, config)).to_string();
3686                    let prefix_width = if starts_item {
3687                        self.calculate_string_length(&marker)
3688                    } else {
3689                        indent_size
3690                    };
3691                    self.calculate_effective_length(&joined) + prefix_width + hard_break_width
3692                        <= config.line_length.get()
3693                };
3694
3695                // How many leading lines of a paragraph are kept as written: all
3696                // of them when every line is exempt, else the link reference
3697                // definitions opening it. After a task checkbox the item's first
3698                // line is inline text, so it is never a definition.
3699                let item_opens_with_checkbox = marker_len > base_marker_len;
3700                let verbatim_line_count = |block_idx: usize, para_lines: &[(String, usize)]| -> usize {
3701                    if para_lines
3702                        .iter()
3703                        .all(|(line, line_num)| is_exempt_line(line, *line_num))
3704                    {
3705                        para_lines.len()
3706                    } else {
3707                        let skip_first = item_opens_with_checkbox && block_idx == 0;
3708                        para_lines
3709                            .iter()
3710                            .enumerate()
3711                            .take_while(|(idx, (line, _))| !(skip_first && *idx == 0) && may_be_link_ref_def(line))
3712                            .count()
3713                    }
3714                };
3715
3716                // A run of prose lines between hard breaks that spans several
3717                // physical lines. The reflow joins the runs that fit on one line
3718                // and writes the others back as they were, so an item with no
3719                // fitting run produces no change and no warning. Link reference
3720                // definitions opening a paragraph are kept as written and never
3721                // joined.
3722                let has_wrapped_prose_run = || {
3723                    let ends_with_hard_break = |line: &str, line_num: usize| {
3724                        has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3725                    };
3726                    blocks.iter().enumerate().any(|(block_idx, block)| {
3727                        let Block::Paragraph { lines: para_lines, .. } = block else {
3728                            return false;
3729                        };
3730                        let verbatim = verbatim_line_count(block_idx, para_lines);
3731                        split_into_segments(&para_lines[verbatim..], ends_with_hard_break)
3732                            .iter()
3733                            .any(|segment| segment.len() > 1)
3734                    })
3735                };
3736
3737                let needs_reflow = !contains_definition_list
3738                    && !holds_definition_list(ctx, list_start, i - 1)
3739                    && match config.reflow_mode {
3740                        ReflowMode::SentencePack => true,
3741                        ReflowMode::Normalize => {
3742                            // Only reflow if:
3743                            // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
3744                            // 2. Any admonition content line exceeds the limit, OR
3745                            // 3. The list item should be normalized (has multi-line plain text)
3746                            let any_paragraph_exceeds = blocks.iter().any(|block| match block {
3747                                Block::Paragraph { lines: para_lines, .. } => {
3748                                    if para_lines
3749                                        .iter()
3750                                        .all(|(line, line_num)| is_exempt_line(line, *line_num))
3751                                    {
3752                                        return false;
3753                                    }
3754                                    let joined = Self::join_paragraph_lines(
3755                                        &para_lines.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
3756                                        config,
3757                                    );
3758                                    let with_marker =
3759                                        format!("{}{}", " ".repeat(indent_size), trim_breakable_whitespace(&joined));
3760                                    self.calculate_effective_length(&with_marker) > config.line_length.get()
3761                                }
3762                                Block::Admonition {
3763                                    content_lines,
3764                                    header_indent,
3765                                    ..
3766                                } => content_lines.iter().any(|(content, indent)| {
3767                                    if content.is_empty() {
3768                                        return false;
3769                                    }
3770                                    let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
3771                                    self.calculate_effective_length(&with_indent) > config.line_length.get()
3772                                }),
3773                                _ => false,
3774                            });
3775                            if any_paragraph_exceeds {
3776                                true
3777                            } else {
3778                                should_normalize()
3779                            }
3780                        }
3781                        ReflowMode::SentencePerLine => {
3782                            // Check if list item has multiple sentences
3783                            let sentences = split_into_sentences(
3784                                &combined_content,
3785                                Some(&defined_references),
3786                                config.require_sentence_capital,
3787                                config.cjk_soft_break,
3788                            );
3789                            sentences.len() > 1 || has_wrapped_prose_run()
3790                        }
3791                        ReflowMode::SemanticLineBreaks => {
3792                            let sentences = split_into_sentences(
3793                                &combined_content,
3794                                Some(&defined_references),
3795                                config.require_sentence_capital,
3796                                config.cjk_soft_break,
3797                            );
3798                            sentences.len() > 1
3799                                || (list_start..i).any(|line_idx| {
3800                                    let line = lines[line_idx];
3801                                    let trimmed = line.trim();
3802                                    if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
3803                                        return false;
3804                                    }
3805                                    self.calculate_effective_length(line) > config.line_length.get()
3806                                })
3807                        }
3808                        ReflowMode::Default => {
3809                            // In default mode, only reflow if any individual non-exempt line exceeds limit
3810                            (list_start..i).any(|line_idx| {
3811                                let line = lines[line_idx];
3812                                let trimmed = line.trim();
3813                                // Skip blank lines and exempt lines
3814                                if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
3815                                    return false;
3816                                }
3817                                self.calculate_effective_length(line) > config.line_length.get()
3818                            })
3819                        }
3820                    };
3821
3822                if needs_reflow {
3823                    let start_range = ctx.whole_line_byte_range(list_start + 1);
3824                    let end_line = i - 1;
3825                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
3826                        ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
3827                    } else {
3828                        ctx.whole_line_byte_range(end_line + 1)
3829                    };
3830                    let byte_range = start_range.start..end_range.end;
3831
3832                    // Reflow each block (paragraphs only, preserve code blocks)
3833                    // When line_length = 0 (no limit), use a very large value for reflow
3834                    let reflow_line_length = if config.line_length.is_unlimited() {
3835                        usize::MAX
3836                    } else {
3837                        config.line_length.get().saturating_sub(indent_size).max(1)
3838                    };
3839                    let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
3840
3841                    let mut result: Vec<String> = Vec::new();
3842                    let mut is_first_block = true;
3843
3844                    for (block_idx, block) in blocks.iter().enumerate() {
3845                        match block {
3846                            Block::Paragraph { lines: para_lines, .. } => {
3847                                // A paragraph whose lines are all exempt (link ref defs,
3848                                // standalone links) is kept as written, since reflowing
3849                                // would corrupt link ref defs. Link reference definitions
3850                                // opening a paragraph are kept as written too: a definition
3851                                // ends at its line, so text joined onto its destination
3852                                // would turn it into visible text. Only what follows is
3853                                // reflowed.
3854                                let verbatim = verbatim_line_count(block_idx, para_lines);
3855                                let (verbatim_lines, prose_lines) = para_lines.split_at(verbatim);
3856
3857                                for (line, _) in verbatim_lines {
3858                                    if is_first_block {
3859                                        // The item's first line still carries the
3860                                        // marker padding past its first space, which
3861                                        // `marker` already accounts for.
3862                                        result.push(format!("{marker}{}", line.trim_start()));
3863                                        is_first_block = false;
3864                                    } else {
3865                                        result.push(format!("{expected_indent}{line}"));
3866                                    }
3867                                }
3868                                if !prose_lines.is_empty() {
3869                                    // Split the paragraph into segments at hard break boundaries
3870                                    // Each segment can be reflowed independently
3871                                    // Trailing spaces that a code span carries across
3872                                    // the line break are code, not a hard break.
3873                                    let ends_with_hard_break = |line: &str, line_num: usize| {
3874                                        has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3875                                    };
3876                                    let segments = split_into_segments(prose_lines, ends_with_hard_break);
3877
3878                                    for (segment_idx, segment) in segments.iter().enumerate() {
3879                                        // Check if this segment ends with a hard break and what type
3880                                        let hard_break_type = segment.last().and_then(|(line, line_num)| {
3881                                            let line = line.strip_suffix('\r').unwrap_or(line);
3882                                            if !ends_with_hard_break(line, *line_num) {
3883                                                None
3884                                            } else if line.ends_with('\\') {
3885                                                Some("\\")
3886                                            } else if line.ends_with("  ") {
3887                                                Some("  ")
3888                                            } else {
3889                                                None
3890                                            }
3891                                        });
3892
3893                                        // Join and reflow the segment (removing the hard break marker for processing)
3894                                        let segment_for_reflow: Vec<String> = segment
3895                                            .iter()
3896                                            .map(|(line, line_num)| {
3897                                                // Strip hard break marker (2 spaces or backslash) for reflow processing
3898                                                if !ends_with_hard_break(line, *line_num) {
3899                                                    line.clone()
3900                                                } else if line.ends_with('\\') {
3901                                                    line[..line.len() - 1].trim_end_matches([' ', '\t']).to_string()
3902                                                } else if line.ends_with("  ") {
3903                                                    line[..line.len() - 2].trim_end_matches([' ', '\t']).to_string()
3904                                                } else {
3905                                                    line.clone()
3906                                                }
3907                                            })
3908                                            .collect();
3909
3910                                        let segment_text = trim_breakable_whitespace(&Self::join_paragraph_lines(
3911                                            &segment_for_reflow,
3912                                            config,
3913                                        ))
3914                                        .to_string();
3915                                        // A single sentence over several lines that would not
3916                                        // fit on one line stays as written, hard break and all.
3917                                        let starts_item = is_first_block && segment_idx == 0;
3918                                        if config.reflow_mode == ReflowMode::SentencePerLine
3919                                            && segment.len() > 1
3920                                            && !run_fits_on_one_line(segment, starts_item)
3921                                            && split_into_sentences(
3922                                                &segment_text,
3923                                                Some(&defined_references),
3924                                                config.require_sentence_capital,
3925                                                config.cjk_soft_break,
3926                                            )
3927                                            .len()
3928                                                == 1
3929                                        {
3930                                            for (_, line_num) in segment {
3931                                                let written = ctx.lines[line_num - 1].content(ctx.content);
3932                                                result.push(written.strip_suffix('\r').unwrap_or(written).to_string());
3933                                            }
3934                                            is_first_block = false;
3935                                        } else if !segment_text.is_empty() {
3936                                            reflow_options.first_line_length = if config.reflow_mode
3937                                                == ReflowMode::SentencePack
3938                                                && is_first_block
3939                                                && segment_idx == 0
3940                                                && !config.line_length.is_unlimited()
3941                                            {
3942                                                Some(
3943                                                    config
3944                                                        .line_length
3945                                                        .effective_limit()
3946                                                        .saturating_sub(self.calculate_string_length(&marker))
3947                                                        .max(1),
3948                                                )
3949                                            } else {
3950                                                None
3951                                            };
3952                                            let mut reflowed =
3953                                                crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
3954
3955                                            if is_first_block && segment_idx == 0 {
3956                                                // An item's text can open containers of its own
3957                                                // (`- > quote`, `- - nested`). A line holding only
3958                                                // their markers opens them empty, and the text
3959                                                // after it then sits outside them, so the markers
3960                                                // keep their first word even when it overflows.
3961                                                while reflowed.len() > 1 && holds_only_container_markers(&reflowed[0]) {
3962                                                    let next = reflowed.remove(1);
3963                                                    reflowed[0] = format!("{} {next}", reflowed[0]);
3964                                                }
3965                                                // First segment of first block starts with marker
3966                                                result.push(format!("{marker}{}", reflowed[0]));
3967                                                for line in reflowed.iter().skip(1) {
3968                                                    result.push(format!("{expected_indent}{line}"));
3969                                                }
3970                                                is_first_block = false;
3971                                            } else {
3972                                                // Subsequent segments
3973                                                for line in reflowed {
3974                                                    result.push(format!("{expected_indent}{line}"));
3975                                                }
3976                                            }
3977
3978                                            // If this segment had a hard break, add it back to the last line
3979                                            // Preserve the original hard break format (backslash or two spaces)
3980                                            if let Some(break_marker) = hard_break_type
3981                                                && let Some(last_line) = result.last_mut()
3982                                            {
3983                                                last_line.push_str(break_marker);
3984                                            }
3985                                        }
3986                                    }
3987                                }
3988
3989                                // Separate the next block as the source did. Only add a
3990                                // blank line if not already ending with one (avoids double blanks).
3991                                if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
3992                                    && result.last().is_none_or(|s: &String| !s.is_empty())
3993                                {
3994                                    result.push(String::new());
3995                                }
3996                            }
3997                            Block::Code {
3998                                lines: code_lines,
3999                                has_preceding_blank: _,
4000                            } => {
4001                                // Preserve code blocks as-is with original indentation
4002                                // NOTE: Blank line before code block is handled by the previous block
4003                                // (see paragraph block's logic above)
4004
4005                                for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
4006                                    if is_first_block && idx == 0 {
4007                                        // First line of first block gets marker
4008                                        result.push(format!(
4009                                            "{marker}{}",
4010                                            " ".repeat(orig_indent - marker_len) + content.as_str()
4011                                        ));
4012                                        is_first_block = false;
4013                                    } else if content.is_empty() {
4014                                        result.push(String::new());
4015                                    } else {
4016                                        // Shift nested code with the marker so it stays
4017                                        // aligned under content when MD030 widens spacing.
4018                                        result.push(format!(
4019                                            "{}{}",
4020                                            " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
4021                                            content
4022                                        ));
4023                                    }
4024                                }
4025                            }
4026                            Block::SemanticLine {
4027                                content,
4028                                has_preceding_blank,
4029                                has_following_blank,
4030                            } => {
4031                                // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
4032                                // Without a blank line around it the marker line continues the
4033                                // paragraph it sits in, so a blank line is written only where the
4034                                // source had one; adding one would split that paragraph in two.
4035                                if *has_preceding_blank
4036                                    && !is_first_block
4037                                    && result.last().is_none_or(|s: &String| !s.is_empty())
4038                                {
4039                                    result.push(String::new());
4040                                }
4041
4042                                if is_first_block {
4043                                    // First block starts with marker
4044                                    result.push(format!("{marker}{content}"));
4045                                    is_first_block = false;
4046                                } else {
4047                                    // Subsequent blocks use expected indent
4048                                    result.push(format!("{expected_indent}{content}"));
4049                                }
4050
4051                                if *has_following_blank && block_idx < blocks.len() - 1 {
4052                                    result.push(String::new());
4053                                }
4054                            }
4055                            Block::SnippetLine { content, .. } | Block::DivMarker { content, .. } => {
4056                                // Snippet delimiters (-8<-) and div markers (::: opening or
4057                                // closing) keep their own line, and the blank line the
4058                                // source had after one.
4059                                if is_first_block {
4060                                    result.push(format!("{marker}{content}"));
4061                                    is_first_block = false;
4062                                } else {
4063                                    result.push(format!("{expected_indent}{content}"));
4064                                }
4065                                if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank) {
4066                                    result.push(String::new());
4067                                }
4068                            }
4069                            Block::Html {
4070                                lines: html_lines,
4071                                has_preceding_blank: _,
4072                            } => {
4073                                // HTML text keeps its indentation past the item's content
4074                                // column, which is part of the block (inside a `<pre>`,
4075                                // visibly), so it moves with the marker like code does.
4076                                // NOTE: Blank line before HTML block is handled by the previous block
4077
4078                                for (idx, (line, orig_indent)) in html_lines.iter().enumerate() {
4079                                    if is_first_block && idx == 0 {
4080                                        // First line of first block gets marker
4081                                        result.push(format!("{marker}{line}"));
4082                                        is_first_block = false;
4083                                    } else if line.is_empty() {
4084                                        // Preserve blank lines inside HTML blocks
4085                                        result.push(String::new());
4086                                    } else {
4087                                        result.push(format!(
4088                                            "{}{line}",
4089                                            " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize)
4090                                        ));
4091                                    }
4092                                }
4093
4094                                // Add blank line after HTML block if there's a next block.
4095                                // Only add if not already ending with one (avoids double blanks
4096                                // when the HTML block itself contained a trailing blank line).
4097                                if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4098                                    && result.last().is_none_or(|s: &String| !s.is_empty())
4099                                {
4100                                    result.push(String::new());
4101                                }
4102                            }
4103                            Block::Table {
4104                                lines: table_lines,
4105                                has_preceding_blank: _,
4106                            } => {
4107                                // Preserve table rows verbatim with their original indentation.
4108                                // Reflowing rows would corrupt column alignment and inject `|`
4109                                // characters mid-paragraph (issue #590).
4110                                // The leading blank line is emitted by the previous block.
4111                                for (idx, (content, orig_indent)) in table_lines.iter().enumerate() {
4112                                    if is_first_block && idx == 0 {
4113                                        // First line of first block gets the list marker
4114                                        result.push(format!(
4115                                            "{marker}{}",
4116                                            " ".repeat(orig_indent.saturating_sub(marker_len)) + content.as_str()
4117                                        ));
4118                                        is_first_block = false;
4119                                    } else {
4120                                        // Shift nested table rows with the marker so they
4121                                        // stay aligned when MD030 widens marker spacing.
4122                                        result.push(format!(
4123                                            "{}{}",
4124                                            " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
4125                                            content
4126                                        ));
4127                                    }
4128                                }
4129
4130                                // Add blank line after table block if there's a next block.
4131                                if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4132                                    && result.last().is_none_or(|s: &String| !s.is_empty())
4133                                {
4134                                    result.push(String::new());
4135                                }
4136                            }
4137                            Block::Admonition {
4138                                header,
4139                                header_indent,
4140                                content_lines: admon_lines,
4141                            } => {
4142                                // Reconstruct admonition block with header at original indent
4143                                // and body content reflowed to fit within the line length limit
4144
4145                                // Add blank line before admonition if not first block
4146                                if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
4147                                    result.push(String::new());
4148                                }
4149
4150                                // Output the header at its original indent
4151                                let header_indent_str = " ".repeat(*header_indent);
4152                                if is_first_block {
4153                                    result.push(format!(
4154                                        "{marker}{}",
4155                                        " ".repeat(header_indent.saturating_sub(marker_len)) + header.as_str()
4156                                    ));
4157                                    is_first_block = false;
4158                                } else {
4159                                    result.push(format!("{header_indent_str}{header}"));
4160                                }
4161
4162                                // Derive body indent from the first non-empty content line's
4163                                // stored indent, falling back to header_indent + 4 for
4164                                // empty-body admonitions
4165                                let body_indent = admon_lines
4166                                    .iter()
4167                                    .find(|(content, _)| !content.is_empty())
4168                                    .map_or(header_indent + 4, |(_, indent)| *indent);
4169                                let body_indent_str = " ".repeat(body_indent);
4170
4171                                // Segment body content into code blocks (verbatim) and
4172                                // text paragraphs (reflowable), separated by blank lines.
4173                                // Code lines store (content, orig_indent) to reconstruct
4174                                // internal indentation relative to body_indent.
4175                                enum AdmonSegment {
4176                                    Text(Vec<String>),
4177                                    Code(Vec<(String, usize)>),
4178                                }
4179
4180                                let mut segments: Vec<AdmonSegment> = Vec::new();
4181                                let mut current_text: Vec<String> = Vec::new();
4182                                let mut current_code: Vec<(String, usize)> = Vec::new();
4183                                let mut in_admon_code = false;
4184                                // Track the opening fence character so closing fences
4185                                // must match (backticks close backticks, tildes close tildes)
4186                                let mut fence_char: char = '`';
4187
4188                                // Opening fences: ``` or ~~~ followed by optional info string
4189                                let get_opening_fence = |s: &str| -> Option<(char, usize)> {
4190                                    let t = s.trim_start();
4191                                    if t.starts_with("```") {
4192                                        Some(('`', t.bytes().take_while(|&b| b == b'`').count()))
4193                                    } else if t.starts_with("~~~") {
4194                                        Some(('~', t.bytes().take_while(|&b| b == b'~').count()))
4195                                    } else {
4196                                        None
4197                                    }
4198                                };
4199                                // Closing fences: ONLY fence chars + optional trailing spaces
4200                                let get_closing_fence = |s: &str| -> Option<(char, usize)> {
4201                                    let t = s.trim();
4202                                    if t.starts_with("```") && t.bytes().all(|b| b == b'`') {
4203                                        Some(('`', t.len()))
4204                                    } else if t.starts_with("~~~") && t.bytes().all(|b| b == b'~') {
4205                                        Some(('~', t.len()))
4206                                    } else {
4207                                        None
4208                                    }
4209                                };
4210                                let mut fence_len: usize = 3;
4211
4212                                for (content, orig_indent) in admon_lines {
4213                                    if in_admon_code {
4214                                        // Closing fence must use the same character, be
4215                                        // at least as long, and have no info string
4216                                        if let Some((ch, len)) = get_closing_fence(content)
4217                                            && ch == fence_char
4218                                            && len >= fence_len
4219                                        {
4220                                            current_code.push((content.clone(), *orig_indent));
4221                                            in_admon_code = false;
4222                                            segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
4223                                            continue;
4224                                        }
4225                                        current_code.push((content.clone(), *orig_indent));
4226                                    } else if let Some((ch, len)) = get_opening_fence(content) {
4227                                        if !current_text.is_empty() {
4228                                            segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4229                                        }
4230                                        in_admon_code = true;
4231                                        fence_char = ch;
4232                                        fence_len = len;
4233                                        current_code.push((content.clone(), *orig_indent));
4234                                    } else if content.is_empty() {
4235                                        if !current_text.is_empty() {
4236                                            segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4237                                        }
4238                                    } else {
4239                                        current_text.push(content.clone());
4240                                    }
4241                                }
4242                                if in_admon_code && !current_code.is_empty() {
4243                                    segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
4244                                }
4245                                if !current_text.is_empty() {
4246                                    segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4247                                }
4248
4249                                // Build reflow options once for all text segments
4250                                let admon_reflow_length = if config.line_length.is_unlimited() {
4251                                    usize::MAX
4252                                } else {
4253                                    config.line_length.get().saturating_sub(body_indent).max(1)
4254                                };
4255
4256                                let admon_reflow_options = Self::reflow_options(ctx, config, admon_reflow_length);
4257
4258                                // Output each segment
4259                                for segment in &segments {
4260                                    // Blank line before each segment (after the header or previous segment)
4261                                    result.push(String::new());
4262
4263                                    match segment {
4264                                        AdmonSegment::Code(lines) => {
4265                                            for (line, orig_indent) in lines {
4266                                                if line.is_empty() {
4267                                                    // Preserve blank lines inside code blocks
4268                                                    result.push(String::new());
4269                                                } else {
4270                                                    // Reconstruct with body_indent + any extra
4271                                                    // indentation the line had beyond body_indent
4272                                                    let extra = orig_indent.saturating_sub(body_indent);
4273                                                    let indent_str = " ".repeat(body_indent + extra);
4274                                                    result.push(format!("{indent_str}{line}"));
4275                                                }
4276                                            }
4277                                        }
4278                                        AdmonSegment::Text(lines) => {
4279                                            let paragraph_text =
4280                                                trim_breakable_whitespace(&Self::join_paragraph_lines(lines, config))
4281                                                    .to_string();
4282                                            if paragraph_text.is_empty() {
4283                                                continue;
4284                                            }
4285                                            let reflowed = crate::utils::text_reflow::reflow_line(
4286                                                &paragraph_text,
4287                                                &admon_reflow_options,
4288                                            );
4289                                            for line in &reflowed {
4290                                                result.push(format!("{body_indent_str}{line}"));
4291                                            }
4292                                        }
4293                                    }
4294                                }
4295
4296                                // Add blank line after admonition if there's a next block
4297                                if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4298                                    && result.last().is_none_or(|s: &String| !s.is_empty())
4299                                {
4300                                    result.push(String::new());
4301                                }
4302                            }
4303                        }
4304                    }
4305
4306                    let reflowed_text = result.join(line_ending);
4307
4308                    // Preserve trailing newline
4309                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
4310                        format!("{reflowed_text}{line_ending}")
4311                    } else {
4312                        reflowed_text
4313                    };
4314
4315                    // Get the original text to compare
4316                    let original_text = &ctx.content[byte_range.clone()];
4317
4318                    // Physical-line-length scan, shared by the Normalize-mode gate and its
4319                    // message. The list-item reflow preserves code blocks, HTML blocks,
4320                    // admonition headers, fence markers, semantic markers, and snippet/div
4321                    // markers verbatim; only paragraph content and admonition bodies are
4322                    // restructured. Only those lines drive the length warning, so that
4323                    // preserved-but-overlong content does not keep the paragraph-level
4324                    // warning alive when the reflow would not fix that line.
4325                    let should_count_for_length = |line_idx: usize| -> bool {
4326                        let line = lines[line_idx];
4327                        let trimmed = line.trim();
4328                        if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
4329                            return false;
4330                        }
4331                        let info = &ctx.lines[line_idx];
4332                        if info.in_code_block || info.in_html_block {
4333                            return false;
4334                        }
4335                        if info.in_admonition && mkdocs_admonitions::is_admonition_start(line) {
4336                            return false;
4337                        }
4338                        if is_fence_marker(line) || is_semantic_line(line) {
4339                            return false;
4340                        }
4341                        if is_snippet_block_delimiter(line) {
4342                            return false;
4343                        }
4344                        if line.trim_start().starts_with(":::") {
4345                            return false;
4346                        }
4347                        true
4348                    };
4349                    let max_physical_length = (list_start..i)
4350                        .filter(|&idx| should_count_for_length(idx))
4351                        .map(|idx| self.calculate_effective_length(lines[idx]))
4352                        .max()
4353                        .unwrap_or(0);
4354                    // `line-length = 0` means "no limit", so no physical line can be
4355                    // "over"; the message below then describes a structural join rather
4356                    // than a length violation.
4357                    let any_paragraph_line_over =
4358                        !config.line_length.is_unlimited() && max_physical_length > config.line_length.get();
4359
4360                    // Normalize mode reflows list-item prose just like paragraphs:
4361                    // joining continuation lines and re-wrapping to `line-length`.
4362                    // `prose_changed` is true only when the reflow alters the words or
4363                    // line breaks, not when it would merely re-indent continuation
4364                    // lines or trim trailing whitespace. Comparing the texts with each
4365                    // line's leading and trailing whitespace removed isolates "did the
4366                    // words/line breaks change" from "did the surrounding whitespace
4367                    // change". Continuation indentation is MD077's responsibility and
4368                    // trailing whitespace is MD009's; an MD013 warning for either would
4369                    // both duplicate those rules and resurface a persistent advisory on
4370                    // already-fitting items that users disable MD013 fixing to avoid.
4371                    let prose_changed = {
4372                        let stripped = |text: &str| text.lines().map(str::trim).collect::<Vec<_>>().join("\n");
4373                        stripped(original_text) != stripped(&replacement)
4374                    };
4375                    // Warn when the reflow rewraps prose (the normalize feature for
4376                    // list items), or when a physical line genuinely exceeds the limit
4377                    // and the reflow can change something (a true length violation,
4378                    // even if all that changes is the continuation indent). A line that
4379                    // is already optimal in both respects produces no warning.
4380                    let gate_ok = prose_changed || (any_paragraph_line_over && original_text != replacement);
4381                    if gate_ok {
4382                        // Generate an appropriate message based on why reflow is needed
4383                        let message = match config.reflow_mode {
4384                            ReflowMode::SentencePack => "List item should pack complete sentences".to_string(),
4385                            ReflowMode::SentencePerLine => {
4386                                let num_sentences = split_into_sentences(
4387                                    &combined_content,
4388                                    Some(&defined_references),
4389                                    config.require_sentence_capital,
4390                                    config.cjk_soft_break,
4391                                )
4392                                .len();
4393                                let num_lines = content_lines.len();
4394                                if num_lines == 1 {
4395                                    // Single line with multiple sentences
4396                                    format!("Line contains {num_sentences} sentences (one sentence per line required)")
4397                                } else {
4398                                    // Multiple lines - could be split sentences or mixed
4399                                    format!(
4400                                        "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
4401                                    )
4402                                }
4403                            }
4404                            ReflowMode::SemanticLineBreaks => {
4405                                let num_sentences = split_into_sentences(
4406                                    &combined_content,
4407                                    Some(&defined_references),
4408                                    config.require_sentence_capital,
4409                                    config.cjk_soft_break,
4410                                )
4411                                .len();
4412                                format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
4413                            }
4414                            ReflowMode::Normalize => {
4415                                // When a physical line genuinely exceeds the limit, report
4416                                // it as a length violation. Otherwise the reflow is a
4417                                // structural normalization (joining/re-wrapping multi-line
4418                                // content that already fits), mirroring the paragraph path.
4419                                if any_paragraph_line_over {
4420                                    format!(
4421                                        "Line length {} exceeds {} characters",
4422                                        max_physical_length,
4423                                        config.line_length.get()
4424                                    )
4425                                } else {
4426                                    format!(
4427                                        "List item could be normalized to use line length of {} characters",
4428                                        config.line_length.get()
4429                                    )
4430                                }
4431                            }
4432                            ReflowMode::Default => {
4433                                // Report the actual longest non-exempt line, not the combined content
4434                                let max_length = (list_start..i)
4435                                    .filter(|&line_idx| {
4436                                        let line = lines[line_idx];
4437                                        let trimmed = line.trim();
4438                                        !trimmed.is_empty() && !is_exempt_line(line, line_idx + 1)
4439                                    })
4440                                    .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
4441                                    .max()
4442                                    .unwrap_or(0);
4443                                format!(
4444                                    "Line length {} exceeds {} characters",
4445                                    max_length,
4446                                    config.line_length.get()
4447                                )
4448                            }
4449                        };
4450
4451                        warnings.push(LintWarning {
4452                            rule_name: Some(self.name().to_string()),
4453                            message,
4454                            line: list_start + 1,
4455                            column: 1,
4456                            end_line: end_line + 1,
4457                            end_column: lines[end_line].chars().count() + 1,
4458                            severity: Severity::Warning,
4459                            fix: Some(crate::rule::Fix::new(byte_range, replacement)),
4460                        });
4461                    }
4462                }
4463                continue;
4464            }
4465
4466            // A definition's text reflows below, inside the definition. The rest
4467            // of a definition list is left as written: a term is one line of its
4468            // own, and a line holding a definition's marker but none of its text
4469            // opens a block the definition nests (a list, a quote, a code block)
4470            // or leaves the text for the lines below it.
4471            let definition_text = ctx.definition_text_at(line_num).cloned();
4472            if definition_text.is_none() && ctx.is_in_definition_list(line_num) {
4473                i += 1;
4474                continue;
4475            }
4476
4477            // Found start of a paragraph - collect all lines in it
4478            let paragraph_start = i;
4479            let mut paragraph_lines = vec![lines[i]];
4480            i += 1;
4481
4482            while i < lines.len() {
4483                let next_line = lines[i];
4484                let next_line_num = i + 1;
4485                let next_trimmed = next_line.trim();
4486
4487                // The checks below read one line at a time, and some of what
4488                // they stop at only looks like a block start. A code span still
4489                // open at the end of the line above proves the parser read on,
4490                // and reflowing the paragraph without the rest of the span
4491                // would rewrite the whitespace the span holds.
4492                if line_ends_in_code_span(ctx, i - 1) {
4493                    paragraph_lines.push(next_line);
4494                    i += 1;
4495                    continue;
4496                }
4497
4498                // Stop at paragraph boundaries
4499                if next_trimmed.is_empty()
4500                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
4501                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
4502                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
4503                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
4504                    || ctx.line_holds_html_block(i)
4505                    // Retain source boundaries before fence-like lines, even
4506                    // when a backtick in the info string makes the fence invalid.
4507                    // Joining these lines can change neighboring GFM autolinks.
4508                    || (ctx.lines[i].visual_indent <= 3
4509                        && (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~")))
4510                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
4511                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
4512                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_block)
4513                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
4514                    || ctx
4515                        .line_info(next_line_num)
4516                        .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
4517                    || (next_line_num > 0
4518                        && next_line_num <= ctx.lines.len()
4519                        && ctx.lines[next_line_num - 1].blockquote.is_some())
4520                    || next_trimmed.starts_with('#')
4521                    // A setext heading ends the paragraph before it. The flag is
4522                    // set on every line of the heading's text, so the paragraph
4523                    // stops at the first of them and absorbs no heading text.
4524                    // That also protects the underline, which can only follow the
4525                    // last text line, so absorbing the construct and joining it
4526                    // into prose is unreachable from here. `is_horizontal_rule`
4527                    // below catches a `---` underline only by coincidence (3+
4528                    // dashes are also a thematic break); `=` and short `-` runs
4529                    // have no such overlap.
4530                    || is_setext_heading_text_line(ctx, next_line_num)
4531                    || TableUtils::is_potential_table_row_with_flavor(next_line, ctx.flavor)
4532                    || opens_bare_item(ctx, i)
4533                    || (is_list_item(next_trimmed)
4534                        && (in_list(paragraph_start) || {
4535                            // Only an ordered marker numbered one interrupts a
4536                            // top-level paragraph. Treating a wrapped `7.` or
4537                            // `819)` as a new item adds list indentation inside
4538                            // any code span that continues onto the next line.
4539                            let digits = next_trimmed.bytes().take_while(u8::is_ascii_digit).count();
4540                            digits == 0 || next_trimmed[..digits].trim_start_matches('0') == "1"
4541                        }))
4542                    || is_horizontal_rule(next_line)
4543                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
4544                    || is_template_directive_only(next_line)
4545                    || is_standalone_attr_list(next_line)
4546                    || is_snippet_block_delimiter(next_line)
4547                    || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
4548                    || is_html_only_line(next_line)
4549                    || self.line_in_multiline_math_block(next_line_num, ctx, config)
4550                    // A line that is one whole `$$...$$` expression renders as a
4551                    // display block, so it ends the paragraph above it and is
4552                    // reflowed on its own. A line touched by a code span crossing
4553                    // one of its boundaries is code, not such a block.
4554                    || ((is_self_contained_display_math_line(next_line)
4555                        || self.line_is_standalone_bracket_math(next_line_num, ctx, config))
4556                        && !line_touches_multiline_code_span(&code_span_touches, next_line_num))
4557                    || standalone_link_ends_paragraph(ctx, next_line_num, config)
4558                    // A definition's text ends where the parser ends it.
4559                    || definition_text
4560                        .as_ref()
4561                        .is_some_and(|text| next_line_num > text.end_line)
4562                {
4563                    break;
4564                }
4565
4566                // Check if the previous line ends with a hard break (2+ spaces or backslash)
4567                if i > 0 && has_hard_break(lines[i - 1]) {
4568                    // Don't include lines after hard breaks in the same paragraph
4569                    break;
4570                }
4571
4572                paragraph_lines.push(next_line);
4573                i += 1;
4574            }
4575
4576            // Compute the common leading indent of all non-empty paragraph lines,
4577            // but only when those lines are structurally inside a list block.
4578            // Indented continuation lines that follow a nested list arrive here
4579            // with their structural indentation intact (e.g. 2 spaces for a
4580            // top-level list item). Stripping the indent before reflow and
4581            // re-applying it afterward prevents the fixer from moving those
4582            // lines to column 0.
4583            //
4584            // The list-block guard is essential: top-level paragraphs that happen
4585            // to start with spaces (insignificant in Markdown) must NOT have those
4586            // spaces preserved or injected by the fixer.
4587            //
4588            // A lazy line falls short of the content of the item the paragraph
4589            // starts in, so its indentation does not count: the reflowed lines
4590            // belong to the item.
4591            //
4592            // Text under a marker with nothing after it is inside the item or
4593            // outside it according to how far its first line is indented, so
4594            // the first line's own indentation is kept for every line. A shared
4595            // indent taken from the other lines, which may be lazy or sit in
4596            // another item of a marker chain, could move the first line across
4597            // that boundary.
4598            let common_indent: String = if paragraph_start > 0 && opens_bare_item(ctx, paragraph_start - 1) {
4599                let first = paragraph_lines[0];
4600                first[..first.len() - first.trim_start().len()].to_string()
4601            } else if in_list(paragraph_start) {
4602                let indent_of = |l: &str| l.len() - l.trim_start().len();
4603                let item_content_col = ctx.lines[..paragraph_start].iter().rev().find_map(|info| {
4604                    info.list_item
4605                        .as_ref()
4606                        // Even an empty marker without source padding has a
4607                        // content column one space beyond the marker. Lazy
4608                        // lines short of it cannot reduce the paragraph indent.
4609                        .map(|item| item.content_column.max(item.marker_column + item.marker.len() + 1))
4610                        .filter(|&col| col <= indent_of(paragraph_lines[0]))
4611                });
4612                let min_len = paragraph_lines
4613                    .iter()
4614                    .filter(|l| !l.trim().is_empty())
4615                    .map(|l| indent_of(l))
4616                    .filter(|&indent| item_content_col.is_none_or(|col| indent >= col))
4617                    .min()
4618                    .unwrap_or(0);
4619                paragraph_lines
4620                    .iter()
4621                    .find(|l| !l.trim().is_empty())
4622                    .map(|l| l[..min_len].to_string())
4623                    .unwrap_or_default()
4624            } else {
4625                String::new()
4626            };
4627
4628            // What the reflowed lines are written after. A definition's text
4629            // starting on its marker line keeps the marker and the spacing the
4630            // author gave it, and its other lines are indented to where the text
4631            // starts, which is the definition's content column, or to four
4632            // columns when that is less. Text starting on a later line, a later
4633            // paragraph of the definition or the part after a hard break, keeps
4634            // the indentation its first line has. Everything else takes the
4635            // common indent: the first line and the rest alike.
4636            let (first_prefix, rest_indent) = match &definition_text {
4637                Some(text) => {
4638                    let indent_width = |line: &str| {
4639                        crate::utils::calculate_indentation_width_default(&line[..line.len() - line.trim_start().len()])
4640                    };
4641                    let text_lines = &lines[text.start_line - 1..text.end_line];
4642                    let marker_prefix = text.marker_prefix_len.map(|len| &text_lines[0][..len]);
4643                    // The colon takes one column, and a tab after it reaches the
4644                    // next tab stop. The other lines take at least four columns
4645                    // even after a narrower marker (`: text`): they continue the
4646                    // paragraph at any indentation in CommonMark, but
4647                    // Python-Markdown keeps a line indented less than four in the
4648                    // definition only when no other definition follows it
4649                    // directly, and otherwise reads the text as terms of a new
4650                    // list.
4651                    let continuation = match marker_prefix {
4652                        Some(prefix) => {
4653                            crate::utils::calculate_indentation_width_default(&prefix.replacen(':', " ", 1)).max(4)
4654                        }
4655                        None => indent_width(text_lines[0]),
4656                    };
4657                    // A hard break splits the text into parts reflowed one at a
4658                    // time. With a line after the break indented less than the
4659                    // continuation, reflowing one part would leave the parts
4660                    // indented differently, and Python-Markdown ends the
4661                    // definition at the first line indented less than the lines
4662                    // before it, so the text is left as written.
4663                    let split_by_hard_break = text_lines[..text_lines.len() - 1]
4664                        .iter()
4665                        .any(|line| has_hard_break(line));
4666                    if split_by_hard_break && text_lines[1..].iter().any(|line| indent_width(line) < continuation) {
4667                        i = paragraph_start + paragraph_lines.len();
4668                        continue;
4669                    }
4670                    match marker_prefix.filter(|_| text.start_line == paragraph_start + 1) {
4671                        Some(prefix) => (prefix.to_string(), " ".repeat(continuation)),
4672                        None => {
4673                            let first_line = lines[paragraph_start];
4674                            let indent = &first_line[..first_line.len() - first_line.trim_start().len()];
4675                            (indent.to_string(), indent.to_string())
4676                        }
4677                    }
4678                }
4679                None => (common_indent.clone(), common_indent.clone()),
4680            };
4681
4682            // Combine paragraph lines into a single string for processing.
4683            // This must be done BEFORE the needs_reflow check for sentence-per-line mode.
4684            let stripped: Vec<&str> = if definition_text.is_some() {
4685                paragraph_lines
4686                    .iter()
4687                    .enumerate()
4688                    .map(|(idx, l)| {
4689                        if idx == 0 {
4690                            &l[first_prefix.len()..]
4691                        } else {
4692                            l.trim_start()
4693                        }
4694                    })
4695                    .collect()
4696            } else {
4697                paragraph_lines
4698                    .iter()
4699                    .map(|l| {
4700                        if l.starts_with(common_indent.as_str()) {
4701                            &l[common_indent.len()..]
4702                        } else {
4703                            l.trim_start()
4704                        }
4705                    })
4706                    .collect()
4707            };
4708            if code_span_runs_into_indentation(&stripped) {
4709                i = paragraph_start + paragraph_lines.len();
4710                continue;
4711            }
4712            let paragraph_text = Self::join_paragraph_lines(&stripped, config);
4713
4714            // A colon-led line with a line of the paragraph before it opens a
4715            // definition, and joining the lines would flatten the definition
4716            // list into prose, so the paragraph is skipped. Its first line is
4717            // prose whatever it starts with, since a definition needs a term on
4718            // the line before it. The indentation a marker is allowed is
4719            // counted from the block's own content, so a list item's
4720            // indentation comes off first. A paragraph holding a line of a
4721            // definition the parser opens on a colon touching its text is
4722            // skipped too when its inline constructs differ between the two
4723            // readings, since the context reports only the parser's.
4724            let contains_definition_list = paragraph_lines.iter().skip(1).any(|line| {
4725                let content = line.strip_prefix(rest_indent.as_str()).unwrap_or(line.trim_start());
4726                crate::utils::text_reflow::is_definition_list_marker(content)
4727            }) || (paragraph_start..paragraph_start + paragraph_lines.len())
4728                .any(|idx| ctx.in_colon_definition(idx + 1));
4729
4730            if contains_definition_list {
4731                // Don't reflow definition lists - skip this paragraph
4732                i = paragraph_start + paragraph_lines.len();
4733                continue;
4734            }
4735
4736            // Skip reflowing if this paragraph contains MkDocs Snippets markers
4737            // Snippets blocks (-8<- ... -8<-) should be preserved exactly
4738            let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
4739
4740            if contains_snippets {
4741                // Don't reflow Snippets blocks - skip this paragraph
4742                i = paragraph_start + paragraph_lines.len();
4743                continue;
4744            }
4745
4746            // Leave a line of a multi-line display-math block as it is, where
4747            // joining lines would corrupt the equation (see
4748            // `line_in_multiline_math_block`). Only the first line has to be
4749            // asked about: such a line ends the paragraph above it, so a
4750            // paragraph reaching here holds one only when it starts on one.
4751            //
4752            // Only that line is passed over, not the rest of what was collected
4753            // with it: prose written directly under the closing delimiter is an
4754            // ordinary paragraph and still reflows.
4755            if self.line_in_multiline_math_block(paragraph_start + 1, ctx, config) {
4756                i = paragraph_start + 1;
4757                continue;
4758            }
4759
4760            // A line that is one whole `$$...$$` expression is a display block:
4761            // it keeps the line it was written on, and the prose under it is an
4762            // ordinary paragraph that still reflows. The line above ended at
4763            // this one, so a paragraph reaching here holds one only when it
4764            // starts on one. A paragraph can start on a line touched by a code
4765            // span crossing one of its boundaries, under a hard break the span
4766            // holds, and its first line is code then.
4767            if (is_self_contained_display_math_line(lines[paragraph_start])
4768                || self.line_is_standalone_bracket_math(paragraph_start + 1, ctx, config))
4769                && !line_touches_multiline_code_span(&code_span_touches, paragraph_start + 1)
4770            {
4771                i = paragraph_start + 1;
4772                continue;
4773            }
4774
4775            // Check if this paragraph needs reflowing
4776            let needs_reflow = match config.reflow_mode {
4777                ReflowMode::SentencePack => true,
4778                ReflowMode::Normalize => self.normalize_mode_needs_reflow(paragraph_lines.iter().copied(), config),
4779                ReflowMode::SentencePerLine => {
4780                    // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
4781                    // Note: we check the joined text because sentences can span multiple lines
4782                    let sentences = split_into_sentences(
4783                        &paragraph_text,
4784                        Some(&defined_references),
4785                        config.require_sentence_capital,
4786                        config.cjk_soft_break,
4787                    );
4788
4789                    // Always reflow if multiple sentences on one line
4790                    if sentences.len() > 1 {
4791                        true
4792                    } else if paragraph_lines.len() > 1 {
4793                        // For single-sentence paragraphs spanning multiple lines:
4794                        // Reflow if they COULD fit on one line (respecting line-length constraint)
4795                        if config.line_length.is_unlimited() {
4796                            // No line-length constraint - always join single sentences
4797                            true
4798                        } else {
4799                            // Only join if it fits within line-length.
4800                            // paragraph_text has the prefix stripped, so add back
4801                            // the prefix of the first line, the one line the
4802                            // joined sentence is written on.
4803                            let effective_length =
4804                                self.calculate_effective_length(&paragraph_text) + first_prefix.len();
4805                            effective_length <= config.line_length.get()
4806                        }
4807                    } else {
4808                        false
4809                    }
4810                }
4811                ReflowMode::SemanticLineBreaks => {
4812                    let sentences = split_into_sentences(
4813                        &paragraph_text,
4814                        Some(&defined_references),
4815                        config.require_sentence_capital,
4816                        config.cjk_soft_break,
4817                    );
4818                    // Reflow if multiple sentences, multiple lines, or any line exceeds limit
4819                    sentences.len() > 1
4820                        || paragraph_lines.len() > 1
4821                        || paragraph_lines
4822                            .iter()
4823                            .any(|line| self.calculate_effective_length(line) > config.line_length.get())
4824                }
4825                ReflowMode::Default => {
4826                    // In default mode, only reflow if lines exceed limit
4827                    paragraph_lines
4828                        .iter()
4829                        .any(|line| self.calculate_effective_length(line) > config.line_length.get())
4830                }
4831            };
4832
4833            if needs_reflow {
4834                // Calculate byte range for this paragraph
4835                // Use whole_line_range for each line and combine
4836                let start_range = ctx.whole_line_byte_range(paragraph_start + 1);
4837                let end_line = paragraph_start + paragraph_lines.len() - 1;
4838
4839                // For the last line, we want to preserve any trailing newline
4840                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
4841                    // Last line without trailing newline - use line_text_range
4842                    ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
4843                } else {
4844                    // Not the last line or has trailing newline - use whole_line_range
4845                    ctx.whole_line_byte_range(end_line + 1)
4846                };
4847
4848                let byte_range = start_range.start..end_range.end;
4849
4850                // Check if the paragraph ends with a hard break and what type
4851                let hard_break_type = paragraph_lines.last().and_then(|line| {
4852                    let line = line.strip_suffix('\r').unwrap_or(line);
4853                    if line.ends_with('\\') {
4854                        Some("\\")
4855                    } else if line.ends_with("  ") {
4856                        Some("  ")
4857                    } else {
4858                        None
4859                    }
4860                });
4861
4862                // Reflow the paragraph
4863                // When line_length = 0 (no limit), use a very large value for reflow
4864                let reflow_line_length = if config.line_length.is_unlimited() {
4865                    usize::MAX
4866                } else {
4867                    config.line_length.get().saturating_sub(rest_indent.len()).max(1)
4868                };
4869                let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
4870                reflow_options.wrap_code_spans =
4871                    config.reflow_break_code_spans && first_prefix.is_empty() && rest_indent.is_empty();
4872                if config.reflow_mode == ReflowMode::SentencePack && !config.line_length.is_unlimited() {
4873                    reflow_options.first_line_length = Some(
4874                        config
4875                            .line_length
4876                            .effective_limit()
4877                            .saturating_sub(self.calculate_string_length(&first_prefix))
4878                            .max(1),
4879                    );
4880                }
4881                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
4882
4883                // Re-apply the prefix to each non-empty reflowed line so that the
4884                // replacement preserves the original structural indentation.
4885                for (idx, line) in reflowed.iter_mut().enumerate() {
4886                    let prefix = if idx == 0 { &first_prefix } else { &rest_indent };
4887                    if !line.is_empty() && !prefix.is_empty() {
4888                        *line = format!("{prefix}{line}");
4889                    }
4890                }
4891
4892                // If the original paragraph ended with a hard break, preserve it
4893                // Preserve the original hard break format (backslash or two spaces)
4894                if let Some(break_marker) = hard_break_type
4895                    && !reflowed.is_empty()
4896                {
4897                    let last_idx = reflowed.len() - 1;
4898                    if !has_hard_break(&reflowed[last_idx]) {
4899                        reflowed[last_idx].push_str(break_marker);
4900                    }
4901                }
4902
4903                let reflowed_text = reflowed.join(line_ending);
4904
4905                // Preserve trailing newline if the original paragraph had one
4906                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
4907                    format!("{reflowed_text}{line_ending}")
4908                } else {
4909                    reflowed_text
4910                };
4911
4912                // Get the original text to compare
4913                let original_text = &ctx.content[byte_range.clone()];
4914
4915                // Only generate a warning if the replacement is different from the original
4916                if original_text != replacement {
4917                    // Determine which line ranges and messages to report based on the reflow mode.
4918                    let warnings_to_report: Vec<(usize, usize, String)> = match config.reflow_mode {
4919                        ReflowMode::SentencePack => vec![(
4920                            paragraph_start + 1,
4921                            end_line + 1,
4922                            "Paragraph should pack complete sentences".to_string(),
4923                        )],
4924                        ReflowMode::Default => {
4925                            // In default mode, report a warning for *every* line in the paragraph
4926                            // that exceeds the limit. Each warning will carry the same paragraph-level
4927                            // fix, making all of them auto-fixable.
4928                            paragraph_lines
4929                                .iter()
4930                                .enumerate()
4931                                .filter(|(_, line)| self.calculate_effective_length(line) > config.line_length.get())
4932                                .map(|(idx, _)| {
4933                                    let violating_line = paragraph_start + idx + 1;
4934                                    (
4935                                        violating_line,
4936                                        violating_line,
4937                                        format!("Line length exceeds {} characters", config.line_length.get()),
4938                                    )
4939                                })
4940                                .collect()
4941                        }
4942                        ReflowMode::Normalize => {
4943                            // In normalize mode, report the whole paragraph as needing normalization.
4944                            vec![(
4945                                paragraph_start + 1,
4946                                end_line + 1,
4947                                format!(
4948                                    "Paragraph could be normalized to use line length of {} characters",
4949                                    config.line_length.get()
4950                                ),
4951                            )]
4952                        }
4953                        ReflowMode::SentencePerLine => {
4954                            // In sentence-per-line mode, highlight the entire paragraph that needs reformatting.
4955                            let num_sentences = split_into_sentences(
4956                                &paragraph_text,
4957                                Some(&defined_references),
4958                                config.require_sentence_capital,
4959                                config.cjk_soft_break,
4960                            )
4961                            .len();
4962                            let message = if paragraph_lines.len() == 1 {
4963                                // Single line with multiple sentences
4964                                format!("Line contains {num_sentences} sentences (one sentence per line required)")
4965                            } else {
4966                                // Multiple lines - could be split sentences or mixed
4967                                let num_lines = paragraph_lines.len();
4968                                format!(
4969                                    "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
4970                                )
4971                            };
4972                            vec![(paragraph_start + 1, paragraph_start + paragraph_lines.len(), message)]
4973                        }
4974                        ReflowMode::SemanticLineBreaks => {
4975                            // In semantic-line-breaks mode, highlight the entire paragraph.
4976                            let num_sentences = split_into_sentences(
4977                                &paragraph_text,
4978                                Some(&defined_references),
4979                                config.require_sentence_capital,
4980                                config.cjk_soft_break,
4981                            )
4982                            .len();
4983                            vec![(
4984                                paragraph_start + 1,
4985                                paragraph_start + paragraph_lines.len(),
4986                                format!("Paragraph should use semantic line breaks ({num_sentences} sentences)"),
4987                            )]
4988                        }
4989                    };
4990
4991                    // Generate the actual lint warnings. All warnings for this paragraph
4992                    // share the same paragraph-level fix.
4993                    for (w_start, w_end, msg) in warnings_to_report {
4994                        warnings.push(LintWarning {
4995                            rule_name: Some(self.name().to_string()),
4996                            message: msg,
4997                            line: w_start,
4998                            column: 1,
4999                            end_line: w_end,
5000                            end_column: lines[w_end.saturating_sub(1)].chars().count() + 1,
5001                            severity: Severity::Warning,
5002                            fix: Some(crate::rule::Fix::new(byte_range.clone(), replacement.clone())),
5003                        });
5004                    }
5005                }
5006            }
5007        }
5008
5009        warnings
5010    }
5011
5012    /// Calculate string length based on the configured length mode
5013    fn calculate_string_length(&self, s: &str) -> usize {
5014        match self.config.length_mode {
5015            LengthMode::Chars => s.chars().count(),
5016            LengthMode::Visual => s.width(),
5017            LengthMode::Bytes => s.len(),
5018        }
5019    }
5020
5021    /// Calculate effective line length
5022    ///
5023    /// Returns the actual display length of the line using the configured length mode.
5024    fn calculate_effective_length(&self, line: &str) -> usize {
5025        self.calculate_string_length(line)
5026    }
5027
5028    /// Calculate line length with inline link/image URLs removed.
5029    ///
5030    /// For each inline link `[text](url)` or image `![alt](url)` on the line,
5031    /// computes the "savings" from removing the URL portion (keeping only `[text]`
5032    /// or `![alt]`). Returns `effective_length - total_savings`.
5033    ///
5034    /// Handles nested constructs (e.g., `[![img](url)](url)`) by only counting the
5035    /// outermost construct to avoid double-counting.
5036    fn length_without_inline_link_urls(
5037        &self,
5038        effective_length: usize,
5039        line_number: usize,
5040        ctx: &crate::lint_context::LintContext,
5041    ) -> usize {
5042        let line_range = ctx.line_content_byte_range(line_number);
5043        let line_byte_end = line_range.end;
5044
5045        // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
5046        let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
5047
5048        for link in ctx.links_on_line(line_number) {
5049            if link.is_reference {
5050                continue;
5051            }
5052            if !matches!(link.link_type, LinkType::Inline) {
5053                continue;
5054            }
5055            if link.byte_end > line_byte_end {
5056                continue;
5057            }
5058            let text_only_len = 2 + self.calculate_string_length(&link.text);
5059            constructs.push((link.byte_offset, link.byte_end, text_only_len));
5060        }
5061
5062        for image in ctx.images_on_line(line_number) {
5063            if image.is_reference {
5064                continue;
5065            }
5066            if !matches!(image.link_type, LinkType::Inline) {
5067                continue;
5068            }
5069            if image.byte_end > line_byte_end {
5070                continue;
5071            }
5072            let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
5073            constructs.push((image.byte_offset, image.byte_end, text_only_len));
5074        }
5075
5076        if constructs.is_empty() {
5077            return effective_length;
5078        }
5079
5080        // Sort by byte offset to handle overlapping/nested constructs
5081        constructs.sort_by_key(|&(start, _, _)| start);
5082
5083        let mut total_savings: usize = 0;
5084        let mut last_end: usize = 0;
5085
5086        for (start, end, text_only_len) in &constructs {
5087            // Skip constructs nested inside a previously counted one
5088            if *start < last_end {
5089                continue;
5090            }
5091            // Full construct length in configured length mode
5092            let full_source = &ctx.content[*start..*end];
5093            let full_len = self.calculate_string_length(full_source);
5094            total_savings += full_len.saturating_sub(*text_only_len);
5095            last_end = *end;
5096        }
5097
5098        effective_length.saturating_sub(total_savings)
5099    }
5100}