Skip to main content

rumdl_lib/rules/md013_line_length/
mod.rs

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