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