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