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                // Track lines and their types (content, code block, fence, nested list)
1008                #[derive(Clone)]
1009                enum LineType {
1010                    Content(String),
1011                    CodeBlock(String, usize),         // content and original indent
1012                    NestedListItem(String, usize),    // full line content and original indent
1013                    SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
1014                    SnippetLine(String),  // MkDocs Snippets delimiters (-8<-) that must stay on their own line
1015                    DivMarker(String),    // Quarto/Pandoc div markers (::: opening or closing)
1016                    AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
1017                    AdmonitionContent(String, usize), // body content text and original indent
1018                    Empty,
1019                }
1020
1021                let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
1022                i += 1;
1023
1024                // Collect continuation lines using ctx.lines for metadata
1025                while i < lines.len() {
1026                    let line_info = &ctx.lines[i];
1027
1028                    // Use pre-computed is_blank from ctx
1029                    if line_info.is_blank {
1030                        // Empty line - check if next line is indented (part of list item)
1031                        if i + 1 < lines.len() {
1032                            let next_info = &ctx.lines[i + 1];
1033
1034                            // Check if next line is indented enough to be continuation
1035                            if !next_info.is_blank && next_info.indent >= marker_len {
1036                                // This blank line is between paragraphs/blocks in the list item
1037                                list_item_lines.push(LineType::Empty);
1038                                i += 1;
1039                                continue;
1040                            }
1041                        }
1042                        // No indented line after blank, end of list item
1043                        break;
1044                    }
1045
1046                    // Use pre-computed indent from ctx
1047                    let indent = line_info.indent;
1048
1049                    // Valid continuation must be indented at least marker_len
1050                    if indent >= marker_len {
1051                        let trimmed = line_info.content(ctx.content).trim();
1052
1053                        // Use pre-computed in_code_block from ctx
1054                        if line_info.in_code_block {
1055                            list_item_lines.push(LineType::CodeBlock(
1056                                line_info.content(ctx.content)[indent..].to_string(),
1057                                indent,
1058                            ));
1059                            i += 1;
1060                            continue;
1061                        }
1062
1063                        // Check for MkDocs admonition lines inside list items.
1064                        // The flavor detection marks these with in_admonition, so we
1065                        // can classify them as admonition header or body content.
1066                        if line_info.in_admonition {
1067                            let raw_content = line_info.content(ctx.content);
1068                            if mkdocs_admonitions::is_admonition_start(raw_content) {
1069                                let header_text = raw_content[indent..].trim_end().to_string();
1070                                list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
1071                            } else {
1072                                let body_text = raw_content[indent..].trim_end().to_string();
1073                                list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1074                            }
1075                            i += 1;
1076                            continue;
1077                        }
1078
1079                        // Check if this is a SIBLING list item (breaks parent)
1080                        // Nested lists are indented >= marker_len and are PART of the parent item
1081                        // Siblings are at indent < marker_len (at or before parent marker)
1082                        if is_list_item(trimmed) && indent < marker_len {
1083                            // This is a sibling item at same or higher level - end parent item
1084                            break;
1085                        }
1086
1087                        // Check if this is a NESTED list item marker
1088                        // Nested lists should be processed separately UNLESS they're part of a
1089                        // multi-paragraph list item (indicated by a blank line before them OR
1090                        // it's a continuation of an already-started nested list)
1091                        if is_list_item(trimmed) && indent >= marker_len {
1092                            // Check if there was a blank line before this (multi-paragraph context)
1093                            let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
1094
1095                            // Check if we've already seen nested list content (another nested item)
1096                            let has_nested_content = list_item_lines.iter().any(|line| {
1097                                matches!(line, LineType::Content(c) if is_list_item(c.trim()))
1098                                    || matches!(line, LineType::NestedListItem(_, _))
1099                            });
1100
1101                            if !has_blank_before && !has_nested_content {
1102                                // Single-paragraph context with no prior nested items: starts a new item
1103                                // End parent collection; nested list will be processed next
1104                                break;
1105                            }
1106                            // else: multi-paragraph context or continuation of nested list, keep collecting
1107                            // Mark this as a nested list item to preserve its structure
1108                            list_item_lines.push(LineType::NestedListItem(
1109                                line_info.content(ctx.content)[indent..].to_string(),
1110                                indent,
1111                            ));
1112                            i += 1;
1113                            continue;
1114                        }
1115
1116                        // Normal continuation: marker_len to marker_len+3
1117                        if indent <= marker_len + 3 {
1118                            // Extract content (remove indentation and trailing whitespace)
1119                            // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
1120                            // See: https://github.com/rvben/rumdl/issues/76
1121                            let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
1122
1123                            // Check if this is a div marker (::: opening or closing)
1124                            // These must be preserved on their own line, not merged into paragraphs
1125                            if line_info.is_div_marker {
1126                                list_item_lines.push(LineType::DivMarker(content));
1127                            }
1128                            // Check if this is a fence marker (opening or closing)
1129                            // These should be treated as code block lines, not paragraph content
1130                            else if is_fence_marker(&content) {
1131                                list_item_lines.push(LineType::CodeBlock(content, indent));
1132                            }
1133                            // Check if this is a semantic line (NOTE:, WARNING:, etc.)
1134                            else if is_semantic_line(&content) {
1135                                list_item_lines.push(LineType::SemanticLine(content));
1136                            }
1137                            // Check if this is a snippet block delimiter (-8<- or --8<--)
1138                            // These must be preserved on their own lines for MkDocs Snippets extension
1139                            else if is_snippet_block_delimiter(&content) {
1140                                list_item_lines.push(LineType::SnippetLine(content));
1141                            } else {
1142                                list_item_lines.push(LineType::Content(content));
1143                            }
1144                            i += 1;
1145                        } else {
1146                            // indent >= marker_len + 4: indented code block
1147                            list_item_lines.push(LineType::CodeBlock(
1148                                line_info.content(ctx.content)[indent..].to_string(),
1149                                indent,
1150                            ));
1151                            i += 1;
1152                        }
1153                    } else {
1154                        // Not indented enough, end of list item
1155                        break;
1156                    }
1157                }
1158
1159                let indent_size = marker_len;
1160                let expected_indent = " ".repeat(indent_size);
1161
1162                // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
1163                #[derive(Clone)]
1164                enum Block {
1165                    Paragraph(Vec<String>),
1166                    Code {
1167                        lines: Vec<(String, usize)>, // (content, indent) pairs
1168                        has_preceding_blank: bool,   // Whether there was a blank line before this block
1169                    },
1170                    NestedList(Vec<(String, usize)>), // (content, indent) pairs for nested list items
1171                    SemanticLine(String), // Semantic markers like NOTE:, WARNING: that stay on their own line
1172                    SnippetLine(String),  // MkDocs Snippets delimiter that stays on its own line without extra spacing
1173                    DivMarker(String),    // Quarto/Pandoc div marker (::: opening or closing) preserved on its own line
1174                    Html {
1175                        lines: Vec<String>,        // HTML content preserved exactly as-is
1176                        has_preceding_blank: bool, // Whether there was a blank line before this block
1177                    },
1178                    Admonition {
1179                        header: String,                      // e.g. "!!! note" or "??? warning \"Title\""
1180                        header_indent: usize,                // original indent of the header line
1181                        content_lines: Vec<(String, usize)>, // (text, original_indent) pairs for body lines
1182                    },
1183                }
1184
1185                // HTML tag detection helpers
1186                // Block-level HTML tags that should trigger HTML block detection
1187                const BLOCK_LEVEL_TAGS: &[&str] = &[
1188                    "div",
1189                    "details",
1190                    "summary",
1191                    "section",
1192                    "article",
1193                    "header",
1194                    "footer",
1195                    "nav",
1196                    "aside",
1197                    "main",
1198                    "table",
1199                    "thead",
1200                    "tbody",
1201                    "tfoot",
1202                    "tr",
1203                    "td",
1204                    "th",
1205                    "ul",
1206                    "ol",
1207                    "li",
1208                    "dl",
1209                    "dt",
1210                    "dd",
1211                    "pre",
1212                    "blockquote",
1213                    "figure",
1214                    "figcaption",
1215                    "form",
1216                    "fieldset",
1217                    "legend",
1218                    "hr",
1219                    "p",
1220                    "h1",
1221                    "h2",
1222                    "h3",
1223                    "h4",
1224                    "h5",
1225                    "h6",
1226                    "style",
1227                    "script",
1228                    "noscript",
1229                ];
1230
1231                fn is_block_html_opening_tag(line: &str) -> Option<String> {
1232                    let trimmed = line.trim();
1233
1234                    // Check for HTML comments
1235                    if trimmed.starts_with("<!--") {
1236                        return Some("!--".to_string());
1237                    }
1238
1239                    // Check for opening tags
1240                    if trimmed.starts_with('<') && !trimmed.starts_with("</") && !trimmed.starts_with("<!") {
1241                        // Extract tag name from <tagname ...> or <tagname>
1242                        let after_bracket = &trimmed[1..];
1243                        if let Some(end) = after_bracket.find(|c: char| c.is_whitespace() || c == '>' || c == '/') {
1244                            let tag_name = after_bracket[..end].to_lowercase();
1245
1246                            // Only treat as block if it's a known block-level tag
1247                            if BLOCK_LEVEL_TAGS.contains(&tag_name.as_str()) {
1248                                return Some(tag_name);
1249                            }
1250                        }
1251                    }
1252                    None
1253                }
1254
1255                fn is_html_closing_tag(line: &str, tag_name: &str) -> bool {
1256                    let trimmed = line.trim();
1257
1258                    // Special handling for HTML comments
1259                    if tag_name == "!--" {
1260                        return trimmed.ends_with("-->");
1261                    }
1262
1263                    // Check for closing tags: </tagname> or </tagname ...>
1264                    trimmed.starts_with(&format!("</{tag_name}>"))
1265                        || trimmed.starts_with(&format!("</{tag_name}  "))
1266                        || (trimmed.starts_with("</") && trimmed[2..].trim_start().starts_with(tag_name))
1267                }
1268
1269                fn is_self_closing_tag(line: &str) -> bool {
1270                    let trimmed = line.trim();
1271                    trimmed.ends_with("/>")
1272                }
1273
1274                let mut blocks: Vec<Block> = Vec::new();
1275                let mut current_paragraph: Vec<String> = Vec::new();
1276                let mut current_code_block: Vec<(String, usize)> = Vec::new();
1277                let mut current_nested_list: Vec<(String, usize)> = Vec::new();
1278                let mut current_html_block: Vec<String> = Vec::new();
1279                let mut html_tag_stack: Vec<String> = Vec::new();
1280                let mut in_code = false;
1281                let mut in_nested_list = false;
1282                let mut in_html_block = false;
1283                let mut had_preceding_blank = false; // Track if we just saw an empty line
1284                let mut code_block_has_preceding_blank = false; // Track blank before current code block
1285                let mut html_block_has_preceding_blank = false; // Track blank before current HTML block
1286
1287                // Track admonition context for block building
1288                let mut in_admonition_block = false;
1289                let mut admonition_header: Option<(String, usize)> = None; // (header_text, indent)
1290                let mut admonition_content: Vec<(String, usize)> = Vec::new();
1291
1292                // Flush any pending admonition block into `blocks`
1293                let flush_admonition = |blocks: &mut Vec<Block>,
1294                                        in_admonition: &mut bool,
1295                                        header: &mut Option<(String, usize)>,
1296                                        content: &mut Vec<(String, usize)>| {
1297                    if *in_admonition {
1298                        if let Some((h, hi)) = header.take() {
1299                            blocks.push(Block::Admonition {
1300                                header: h,
1301                                header_indent: hi,
1302                                content_lines: std::mem::take(content),
1303                            });
1304                        }
1305                        *in_admonition = false;
1306                    }
1307                };
1308
1309                for line in &list_item_lines {
1310                    match line {
1311                        LineType::Empty => {
1312                            if in_admonition_block {
1313                                // Blank lines inside admonitions separate paragraphs within the body
1314                                admonition_content.push((String::new(), 0));
1315                            } else if in_code {
1316                                current_code_block.push((String::new(), 0));
1317                            } else if in_nested_list {
1318                                current_nested_list.push((String::new(), 0));
1319                            } else if in_html_block {
1320                                // Allow blank lines inside HTML blocks
1321                                current_html_block.push(String::new());
1322                            } else if !current_paragraph.is_empty() {
1323                                blocks.push(Block::Paragraph(current_paragraph.clone()));
1324                                current_paragraph.clear();
1325                            }
1326                            // Mark that we saw a blank line
1327                            had_preceding_blank = true;
1328                        }
1329                        LineType::Content(content) => {
1330                            flush_admonition(
1331                                &mut blocks,
1332                                &mut in_admonition_block,
1333                                &mut admonition_header,
1334                                &mut admonition_content,
1335                            );
1336                            // Check if we're currently in an HTML block
1337                            if in_html_block {
1338                                current_html_block.push(content.clone());
1339
1340                                // Check if this line closes any open HTML tags
1341                                if let Some(last_tag) = html_tag_stack.last() {
1342                                    if is_html_closing_tag(content, last_tag) {
1343                                        html_tag_stack.pop();
1344
1345                                        // If stack is empty, HTML block is complete
1346                                        if html_tag_stack.is_empty() {
1347                                            blocks.push(Block::Html {
1348                                                lines: current_html_block.clone(),
1349                                                has_preceding_blank: html_block_has_preceding_blank,
1350                                            });
1351                                            current_html_block.clear();
1352                                            in_html_block = false;
1353                                        }
1354                                    } else if let Some(new_tag) = is_block_html_opening_tag(content) {
1355                                        // Nested opening tag within HTML block
1356                                        if !is_self_closing_tag(content) {
1357                                            html_tag_stack.push(new_tag);
1358                                        }
1359                                    }
1360                                }
1361                                had_preceding_blank = false;
1362                            } else {
1363                                // Not in HTML block - check if this line starts one
1364                                if let Some(tag_name) = is_block_html_opening_tag(content) {
1365                                    // Flush current paragraph before starting HTML block
1366                                    if in_code {
1367                                        blocks.push(Block::Code {
1368                                            lines: current_code_block.clone(),
1369                                            has_preceding_blank: code_block_has_preceding_blank,
1370                                        });
1371                                        current_code_block.clear();
1372                                        in_code = false;
1373                                    } else if in_nested_list {
1374                                        blocks.push(Block::NestedList(current_nested_list.clone()));
1375                                        current_nested_list.clear();
1376                                        in_nested_list = false;
1377                                    } else if !current_paragraph.is_empty() {
1378                                        blocks.push(Block::Paragraph(current_paragraph.clone()));
1379                                        current_paragraph.clear();
1380                                    }
1381
1382                                    // Start new HTML block
1383                                    in_html_block = true;
1384                                    html_block_has_preceding_blank = had_preceding_blank;
1385                                    current_html_block.push(content.clone());
1386
1387                                    // Check if it's self-closing or needs a closing tag
1388                                    if is_self_closing_tag(content) {
1389                                        // Self-closing tag - complete the HTML block immediately
1390                                        blocks.push(Block::Html {
1391                                            lines: current_html_block.clone(),
1392                                            has_preceding_blank: html_block_has_preceding_blank,
1393                                        });
1394                                        current_html_block.clear();
1395                                        in_html_block = false;
1396                                    } else {
1397                                        // Regular opening tag - push to stack
1398                                        html_tag_stack.push(tag_name);
1399                                    }
1400                                } else {
1401                                    // Regular content line - add to paragraph
1402                                    if in_code {
1403                                        // Switching from code to content
1404                                        blocks.push(Block::Code {
1405                                            lines: current_code_block.clone(),
1406                                            has_preceding_blank: code_block_has_preceding_blank,
1407                                        });
1408                                        current_code_block.clear();
1409                                        in_code = false;
1410                                    } else if in_nested_list {
1411                                        // Switching from nested list to content
1412                                        blocks.push(Block::NestedList(current_nested_list.clone()));
1413                                        current_nested_list.clear();
1414                                        in_nested_list = false;
1415                                    }
1416                                    current_paragraph.push(content.clone());
1417                                }
1418                                had_preceding_blank = false; // Reset after content
1419                            }
1420                        }
1421                        LineType::CodeBlock(content, indent) => {
1422                            flush_admonition(
1423                                &mut blocks,
1424                                &mut in_admonition_block,
1425                                &mut admonition_header,
1426                                &mut admonition_content,
1427                            );
1428                            if in_nested_list {
1429                                // Switching from nested list to code
1430                                blocks.push(Block::NestedList(current_nested_list.clone()));
1431                                current_nested_list.clear();
1432                                in_nested_list = false;
1433                            } else if in_html_block {
1434                                // Switching from HTML block to code (shouldn't happen normally, but handle it)
1435                                blocks.push(Block::Html {
1436                                    lines: current_html_block.clone(),
1437                                    has_preceding_blank: html_block_has_preceding_blank,
1438                                });
1439                                current_html_block.clear();
1440                                html_tag_stack.clear();
1441                                in_html_block = false;
1442                            }
1443                            if !in_code {
1444                                // Switching from content to code
1445                                if !current_paragraph.is_empty() {
1446                                    blocks.push(Block::Paragraph(current_paragraph.clone()));
1447                                    current_paragraph.clear();
1448                                }
1449                                in_code = true;
1450                                // Record whether there was a blank line before this code block
1451                                code_block_has_preceding_blank = had_preceding_blank;
1452                            }
1453                            current_code_block.push((content.clone(), *indent));
1454                            had_preceding_blank = false; // Reset after code
1455                        }
1456                        LineType::NestedListItem(content, indent) => {
1457                            flush_admonition(
1458                                &mut blocks,
1459                                &mut in_admonition_block,
1460                                &mut admonition_header,
1461                                &mut admonition_content,
1462                            );
1463                            if in_code {
1464                                // Switching from code to nested list
1465                                blocks.push(Block::Code {
1466                                    lines: current_code_block.clone(),
1467                                    has_preceding_blank: code_block_has_preceding_blank,
1468                                });
1469                                current_code_block.clear();
1470                                in_code = false;
1471                            } else if in_html_block {
1472                                // Switching from HTML block to nested list (shouldn't happen normally, but handle it)
1473                                blocks.push(Block::Html {
1474                                    lines: current_html_block.clone(),
1475                                    has_preceding_blank: html_block_has_preceding_blank,
1476                                });
1477                                current_html_block.clear();
1478                                html_tag_stack.clear();
1479                                in_html_block = false;
1480                            }
1481                            if !in_nested_list {
1482                                // Switching from content to nested list
1483                                if !current_paragraph.is_empty() {
1484                                    blocks.push(Block::Paragraph(current_paragraph.clone()));
1485                                    current_paragraph.clear();
1486                                }
1487                                in_nested_list = true;
1488                            }
1489                            current_nested_list.push((content.clone(), *indent));
1490                            had_preceding_blank = false; // Reset after nested list
1491                        }
1492                        LineType::SemanticLine(content) => {
1493                            // Semantic lines are standalone - flush any current block and add as separate block
1494                            flush_admonition(
1495                                &mut blocks,
1496                                &mut in_admonition_block,
1497                                &mut admonition_header,
1498                                &mut admonition_content,
1499                            );
1500                            if in_code {
1501                                blocks.push(Block::Code {
1502                                    lines: current_code_block.clone(),
1503                                    has_preceding_blank: code_block_has_preceding_blank,
1504                                });
1505                                current_code_block.clear();
1506                                in_code = false;
1507                            } else if in_nested_list {
1508                                blocks.push(Block::NestedList(current_nested_list.clone()));
1509                                current_nested_list.clear();
1510                                in_nested_list = false;
1511                            } else if in_html_block {
1512                                blocks.push(Block::Html {
1513                                    lines: current_html_block.clone(),
1514                                    has_preceding_blank: html_block_has_preceding_blank,
1515                                });
1516                                current_html_block.clear();
1517                                html_tag_stack.clear();
1518                                in_html_block = false;
1519                            } else if !current_paragraph.is_empty() {
1520                                blocks.push(Block::Paragraph(current_paragraph.clone()));
1521                                current_paragraph.clear();
1522                            }
1523                            // Add semantic line as its own block
1524                            blocks.push(Block::SemanticLine(content.clone()));
1525                            had_preceding_blank = false; // Reset after semantic line
1526                        }
1527                        LineType::SnippetLine(content) => {
1528                            // Snippet delimiters (-8<-) are standalone - flush any current block and add as separate block
1529                            // Unlike semantic lines, snippet lines don't add extra blank lines around them
1530                            flush_admonition(
1531                                &mut blocks,
1532                                &mut in_admonition_block,
1533                                &mut admonition_header,
1534                                &mut admonition_content,
1535                            );
1536                            if in_code {
1537                                blocks.push(Block::Code {
1538                                    lines: current_code_block.clone(),
1539                                    has_preceding_blank: code_block_has_preceding_blank,
1540                                });
1541                                current_code_block.clear();
1542                                in_code = false;
1543                            } else if in_nested_list {
1544                                blocks.push(Block::NestedList(current_nested_list.clone()));
1545                                current_nested_list.clear();
1546                                in_nested_list = false;
1547                            } else if in_html_block {
1548                                blocks.push(Block::Html {
1549                                    lines: current_html_block.clone(),
1550                                    has_preceding_blank: html_block_has_preceding_blank,
1551                                });
1552                                current_html_block.clear();
1553                                html_tag_stack.clear();
1554                                in_html_block = false;
1555                            } else if !current_paragraph.is_empty() {
1556                                blocks.push(Block::Paragraph(current_paragraph.clone()));
1557                                current_paragraph.clear();
1558                            }
1559                            // Add snippet line as its own block
1560                            blocks.push(Block::SnippetLine(content.clone()));
1561                            had_preceding_blank = false;
1562                        }
1563                        LineType::DivMarker(content) => {
1564                            // Div markers (::: opening or closing) are standalone structural delimiters
1565                            // Flush any current block and add as separate block
1566                            flush_admonition(
1567                                &mut blocks,
1568                                &mut in_admonition_block,
1569                                &mut admonition_header,
1570                                &mut admonition_content,
1571                            );
1572                            if in_code {
1573                                blocks.push(Block::Code {
1574                                    lines: current_code_block.clone(),
1575                                    has_preceding_blank: code_block_has_preceding_blank,
1576                                });
1577                                current_code_block.clear();
1578                                in_code = false;
1579                            } else if in_nested_list {
1580                                blocks.push(Block::NestedList(current_nested_list.clone()));
1581                                current_nested_list.clear();
1582                                in_nested_list = false;
1583                            } else if in_html_block {
1584                                blocks.push(Block::Html {
1585                                    lines: current_html_block.clone(),
1586                                    has_preceding_blank: html_block_has_preceding_blank,
1587                                });
1588                                current_html_block.clear();
1589                                html_tag_stack.clear();
1590                                in_html_block = false;
1591                            } else if !current_paragraph.is_empty() {
1592                                blocks.push(Block::Paragraph(current_paragraph.clone()));
1593                                current_paragraph.clear();
1594                            }
1595                            blocks.push(Block::DivMarker(content.clone()));
1596                            had_preceding_blank = false;
1597                        }
1598                        LineType::AdmonitionHeader(header_text, indent) => {
1599                            flush_admonition(
1600                                &mut blocks,
1601                                &mut in_admonition_block,
1602                                &mut admonition_header,
1603                                &mut admonition_content,
1604                            );
1605                            // Flush other current blocks
1606                            if in_code {
1607                                blocks.push(Block::Code {
1608                                    lines: current_code_block.clone(),
1609                                    has_preceding_blank: code_block_has_preceding_blank,
1610                                });
1611                                current_code_block.clear();
1612                                in_code = false;
1613                            } else if in_nested_list {
1614                                blocks.push(Block::NestedList(current_nested_list.clone()));
1615                                current_nested_list.clear();
1616                                in_nested_list = false;
1617                            } else if in_html_block {
1618                                blocks.push(Block::Html {
1619                                    lines: current_html_block.clone(),
1620                                    has_preceding_blank: html_block_has_preceding_blank,
1621                                });
1622                                current_html_block.clear();
1623                                html_tag_stack.clear();
1624                                in_html_block = false;
1625                            } else if !current_paragraph.is_empty() {
1626                                blocks.push(Block::Paragraph(current_paragraph.clone()));
1627                                current_paragraph.clear();
1628                            }
1629                            // Start new admonition block
1630                            in_admonition_block = true;
1631                            admonition_header = Some((header_text.clone(), *indent));
1632                            admonition_content.clear();
1633                            had_preceding_blank = false;
1634                        }
1635                        LineType::AdmonitionContent(content, indent) => {
1636                            if in_admonition_block {
1637                                // Add to current admonition body
1638                                admonition_content.push((content.clone(), *indent));
1639                            } else {
1640                                // Admonition content without a header should not happen,
1641                                // but treat it as regular content to avoid data loss
1642                                current_paragraph.push(content.clone());
1643                            }
1644                            had_preceding_blank = false;
1645                        }
1646                    }
1647                }
1648
1649                // Push all remaining pending blocks independently
1650                flush_admonition(
1651                    &mut blocks,
1652                    &mut in_admonition_block,
1653                    &mut admonition_header,
1654                    &mut admonition_content,
1655                );
1656                if in_code && !current_code_block.is_empty() {
1657                    blocks.push(Block::Code {
1658                        lines: current_code_block,
1659                        has_preceding_blank: code_block_has_preceding_blank,
1660                    });
1661                }
1662                if in_nested_list && !current_nested_list.is_empty() {
1663                    blocks.push(Block::NestedList(current_nested_list));
1664                }
1665                if in_html_block && !current_html_block.is_empty() {
1666                    blocks.push(Block::Html {
1667                        lines: current_html_block,
1668                        has_preceding_blank: html_block_has_preceding_blank,
1669                    });
1670                }
1671                if !current_paragraph.is_empty() {
1672                    blocks.push(Block::Paragraph(current_paragraph));
1673                }
1674
1675                // Helper: check if a line (raw source or stripped content) is exempt
1676                // from line-length checks. Link reference definitions are always exempt;
1677                // standalone link/image lines are exempt when strict mode is off.
1678                // Also checks content after stripping list markers, since list item
1679                // continuation lines may contain link ref defs.
1680                let is_exempt_line = |raw_line: &str| -> bool {
1681                    let trimmed = raw_line.trim();
1682                    // Link reference definitions: always exempt
1683                    if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
1684                        return true;
1685                    }
1686                    // Also check after stripping list markers (for list item content)
1687                    if is_list_item(trimmed) {
1688                        let (_, content) = extract_list_marker_and_content(trimmed);
1689                        let content_trimmed = content.trim();
1690                        if content_trimmed.starts_with('[')
1691                            && content_trimmed.contains("]:")
1692                            && LINK_REF_PATTERN.is_match(content_trimmed)
1693                        {
1694                            return true;
1695                        }
1696                    }
1697                    // Standalone link/image lines: exempt when not strict
1698                    if !config.strict && is_standalone_link_or_image_line(raw_line) {
1699                        return true;
1700                    }
1701                    false
1702                };
1703
1704                // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
1705                // Exclude link reference definitions and standalone link lines from content
1706                // so they don't pollute combined_content or trigger false reflow.
1707                let content_lines: Vec<String> = list_item_lines
1708                    .iter()
1709                    .filter_map(|line| {
1710                        if let LineType::Content(s) = line {
1711                            if is_exempt_line(s) {
1712                                return None;
1713                            }
1714                            Some(s.clone())
1715                        } else {
1716                            None
1717                        }
1718                    })
1719                    .collect();
1720
1721                // Check if we need to reflow this list item
1722                // We check the combined content to see if it exceeds length limits
1723                let combined_content = content_lines.join(" ").trim().to_string();
1724
1725                // Helper to check if we should reflow in normalize mode
1726                let should_normalize = || {
1727                    // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
1728                    // DO normalize if it has plain text content that spans multiple lines
1729                    let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
1730                    let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
1731                    let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
1732                    let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine(_)));
1733                    let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker(_)));
1734                    let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
1735                    let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
1736
1737                    // If we have structural blocks but no paragraphs, don't normalize
1738                    if (has_nested_lists
1739                        || has_code_blocks
1740                        || has_semantic_lines
1741                        || has_snippet_lines
1742                        || has_div_markers
1743                        || has_admonitions)
1744                        && !has_paragraphs
1745                    {
1746                        return false;
1747                    }
1748
1749                    // If we have paragraphs, check if they span multiple lines or there are multiple blocks
1750                    if has_paragraphs {
1751                        // Count only paragraphs that contain at least one non-exempt line.
1752                        // Paragraphs consisting entirely of link ref defs or standalone links
1753                        // should not trigger normalization.
1754                        let paragraph_count = blocks
1755                            .iter()
1756                            .filter(|b| {
1757                                if let Block::Paragraph(para_lines) = b {
1758                                    !para_lines.iter().all(|line| is_exempt_line(line))
1759                                } else {
1760                                    false
1761                                }
1762                            })
1763                            .count();
1764                        if paragraph_count > 1 {
1765                            // Multiple non-exempt paragraph blocks should be normalized
1766                            return true;
1767                        }
1768
1769                        // Single paragraph block: normalize if it has multiple content lines
1770                        if content_lines.len() > 1 {
1771                            return true;
1772                        }
1773                    }
1774
1775                    false
1776                };
1777
1778                let needs_reflow = match config.reflow_mode {
1779                    ReflowMode::Normalize => {
1780                        // Only reflow if:
1781                        // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
1782                        // 2. Any admonition content line exceeds the limit, OR
1783                        // 3. The list item should be normalized (has multi-line plain text)
1784                        let any_paragraph_exceeds = blocks.iter().any(|block| match block {
1785                            Block::Paragraph(para_lines) => {
1786                                if para_lines.iter().all(|line| is_exempt_line(line)) {
1787                                    return false;
1788                                }
1789                                let joined = para_lines.join(" ");
1790                                let with_marker = format!("{}{}", " ".repeat(marker_len), joined.trim());
1791                                self.calculate_effective_length(&with_marker) > config.line_length.get()
1792                            }
1793                            Block::Admonition {
1794                                content_lines,
1795                                header_indent,
1796                                ..
1797                            } => content_lines.iter().any(|(content, indent)| {
1798                                if content.is_empty() {
1799                                    return false;
1800                                }
1801                                let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
1802                                self.calculate_effective_length(&with_indent) > config.line_length.get()
1803                            }),
1804                            _ => false,
1805                        });
1806                        if any_paragraph_exceeds {
1807                            true
1808                        } else {
1809                            should_normalize()
1810                        }
1811                    }
1812                    ReflowMode::SentencePerLine => {
1813                        // Check if list item has multiple sentences
1814                        let sentences = split_into_sentences(&combined_content);
1815                        sentences.len() > 1
1816                    }
1817                    ReflowMode::SemanticLineBreaks => {
1818                        let sentences = split_into_sentences(&combined_content);
1819                        sentences.len() > 1
1820                            || (list_start..i).any(|line_idx| {
1821                                let line = lines[line_idx];
1822                                let trimmed = line.trim();
1823                                if trimmed.is_empty() || is_exempt_line(line) {
1824                                    return false;
1825                                }
1826                                self.calculate_effective_length(line) > config.line_length.get()
1827                            })
1828                    }
1829                    ReflowMode::Default => {
1830                        // In default mode, only reflow if any individual non-exempt line exceeds limit
1831                        (list_start..i).any(|line_idx| {
1832                            let line = lines[line_idx];
1833                            let trimmed = line.trim();
1834                            // Skip blank lines and exempt lines
1835                            if trimmed.is_empty() || is_exempt_line(line) {
1836                                return false;
1837                            }
1838                            self.calculate_effective_length(line) > config.line_length.get()
1839                        })
1840                    }
1841                };
1842
1843                if needs_reflow {
1844                    let start_range = line_index.whole_line_range(list_start + 1);
1845                    let end_line = i - 1;
1846                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1847                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1848                    } else {
1849                        line_index.whole_line_range(end_line + 1)
1850                    };
1851                    let byte_range = start_range.start..end_range.end;
1852
1853                    // Reflow each block (paragraphs only, preserve code blocks)
1854                    // When line_length = 0 (no limit), use a very large value for reflow
1855                    let reflow_line_length = if config.line_length.is_unlimited() {
1856                        usize::MAX
1857                    } else {
1858                        config.line_length.get().saturating_sub(indent_size).max(1)
1859                    };
1860                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
1861                        line_length: reflow_line_length,
1862                        break_on_sentences: true,
1863                        preserve_breaks: false,
1864                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1865                        semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1866                        abbreviations: config.abbreviations_for_reflow(),
1867                        length_mode: self.reflow_length_mode(),
1868                    };
1869
1870                    let mut result: Vec<String> = Vec::new();
1871                    let mut is_first_block = true;
1872
1873                    for (block_idx, block) in blocks.iter().enumerate() {
1874                        match block {
1875                            Block::Paragraph(para_lines) => {
1876                                // If every line in this paragraph is exempt (link ref defs,
1877                                // standalone links), preserve the paragraph verbatim instead
1878                                // of reflowing it. Reflowing would corrupt link ref defs.
1879                                let all_exempt = para_lines.iter().all(|line| is_exempt_line(line));
1880
1881                                if all_exempt {
1882                                    for (idx, line) in para_lines.iter().enumerate() {
1883                                        if is_first_block && idx == 0 {
1884                                            result.push(format!("{marker}{line}"));
1885                                            is_first_block = false;
1886                                        } else {
1887                                            result.push(format!("{expected_indent}{line}"));
1888                                        }
1889                                    }
1890                                } else {
1891                                    // Split the paragraph into segments at hard break boundaries
1892                                    // Each segment can be reflowed independently
1893                                    let segments = split_into_segments(para_lines);
1894
1895                                    for (segment_idx, segment) in segments.iter().enumerate() {
1896                                        // Check if this segment ends with a hard break and what type
1897                                        let hard_break_type = segment.last().and_then(|line| {
1898                                            let line = line.strip_suffix('\r').unwrap_or(line);
1899                                            if line.ends_with('\\') {
1900                                                Some("\\")
1901                                            } else if line.ends_with("  ") {
1902                                                Some("  ")
1903                                            } else {
1904                                                None
1905                                            }
1906                                        });
1907
1908                                        // Join and reflow the segment (removing the hard break marker for processing)
1909                                        let segment_for_reflow: Vec<String> = segment
1910                                            .iter()
1911                                            .map(|line| {
1912                                                // Strip hard break marker (2 spaces or backslash) for reflow processing
1913                                                if line.ends_with('\\') {
1914                                                    line[..line.len() - 1].trim_end().to_string()
1915                                                } else if line.ends_with("  ") {
1916                                                    line[..line.len() - 2].trim_end().to_string()
1917                                                } else {
1918                                                    line.clone()
1919                                                }
1920                                            })
1921                                            .collect();
1922
1923                                        let segment_text = segment_for_reflow.join(" ").trim().to_string();
1924                                        if !segment_text.is_empty() {
1925                                            let reflowed =
1926                                                crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
1927
1928                                            if is_first_block && segment_idx == 0 {
1929                                                // First segment of first block starts with marker
1930                                                result.push(format!("{marker}{}", reflowed[0]));
1931                                                for line in reflowed.iter().skip(1) {
1932                                                    result.push(format!("{expected_indent}{line}"));
1933                                                }
1934                                                is_first_block = false;
1935                                            } else {
1936                                                // Subsequent segments
1937                                                for line in reflowed {
1938                                                    result.push(format!("{expected_indent}{line}"));
1939                                                }
1940                                            }
1941
1942                                            // If this segment had a hard break, add it back to the last line
1943                                            // Preserve the original hard break format (backslash or two spaces)
1944                                            if let Some(break_marker) = hard_break_type
1945                                                && let Some(last_line) = result.last_mut()
1946                                            {
1947                                                last_line.push_str(break_marker);
1948                                            }
1949                                        }
1950                                    }
1951                                }
1952
1953                                // Add blank line after paragraph block if there's a next block.
1954                                // Check if next block is a code block that doesn't want a preceding blank.
1955                                // Also don't add blank lines before snippet lines (they should stay tight).
1956                                // Only add if not already ending with one (avoids double blanks).
1957                                if block_idx < blocks.len() - 1 {
1958                                    let next_block = &blocks[block_idx + 1];
1959                                    let should_add_blank = match next_block {
1960                                        Block::Code {
1961                                            has_preceding_blank, ..
1962                                        } => *has_preceding_blank,
1963                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
1964                                        _ => true, // For all other blocks, add blank line
1965                                    };
1966                                    if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
1967                                    {
1968                                        result.push(String::new());
1969                                    }
1970                                }
1971                            }
1972                            Block::Code {
1973                                lines: code_lines,
1974                                has_preceding_blank: _,
1975                            } => {
1976                                // Preserve code blocks as-is with original indentation
1977                                // NOTE: Blank line before code block is handled by the previous block
1978                                // (see paragraph block's logic above)
1979
1980                                for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
1981                                    if is_first_block && idx == 0 {
1982                                        // First line of first block gets marker
1983                                        result.push(format!(
1984                                            "{marker}{}",
1985                                            " ".repeat(orig_indent - marker_len) + content
1986                                        ));
1987                                        is_first_block = false;
1988                                    } else if content.is_empty() {
1989                                        result.push(String::new());
1990                                    } else {
1991                                        result.push(format!("{}{}", " ".repeat(*orig_indent), content));
1992                                    }
1993                                }
1994                            }
1995                            Block::NestedList(nested_items) => {
1996                                // Preserve nested list items as-is with original indentation.
1997                                // Only add blank before if not already ending with one (avoids
1998                                // double blanks when the preceding block already added one).
1999                                if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2000                                    result.push(String::new());
2001                                }
2002
2003                                for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
2004                                    if is_first_block && idx == 0 {
2005                                        // First line of first block gets marker
2006                                        result.push(format!(
2007                                            "{marker}{}",
2008                                            " ".repeat(orig_indent - marker_len) + content
2009                                        ));
2010                                        is_first_block = false;
2011                                    } else if content.is_empty() {
2012                                        result.push(String::new());
2013                                    } else {
2014                                        result.push(format!("{}{}", " ".repeat(*orig_indent), content));
2015                                    }
2016                                }
2017
2018                                // Add blank line after nested list if there's a next block.
2019                                // Only add if not already ending with one (avoids double blanks
2020                                // when the last nested item was already a blank line).
2021                                if block_idx < blocks.len() - 1 {
2022                                    let next_block = &blocks[block_idx + 1];
2023                                    let should_add_blank = match next_block {
2024                                        Block::Code {
2025                                            has_preceding_blank, ..
2026                                        } => *has_preceding_blank,
2027                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2028                                        _ => true, // For all other blocks, add blank line
2029                                    };
2030                                    if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2031                                    {
2032                                        result.push(String::new());
2033                                    }
2034                                }
2035                            }
2036                            Block::SemanticLine(content) => {
2037                                // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
2038                                // Only add blank before if not already ending with one.
2039                                if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2040                                    result.push(String::new());
2041                                }
2042
2043                                if is_first_block {
2044                                    // First block starts with marker
2045                                    result.push(format!("{marker}{content}"));
2046                                    is_first_block = false;
2047                                } else {
2048                                    // Subsequent blocks use expected indent
2049                                    result.push(format!("{expected_indent}{content}"));
2050                                }
2051
2052                                // Add blank line after semantic line if there's a next block.
2053                                // Only add if not already ending with one.
2054                                if block_idx < blocks.len() - 1 {
2055                                    let next_block = &blocks[block_idx + 1];
2056                                    let should_add_blank = match next_block {
2057                                        Block::Code {
2058                                            has_preceding_blank, ..
2059                                        } => *has_preceding_blank,
2060                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2061                                        _ => true, // For all other blocks, add blank line
2062                                    };
2063                                    if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2064                                    {
2065                                        result.push(String::new());
2066                                    }
2067                                }
2068                            }
2069                            Block::SnippetLine(content) => {
2070                                // Preserve snippet delimiters (-8<-) as-is on their own line
2071                                // Unlike semantic lines, snippet lines don't add extra blank lines
2072                                if is_first_block {
2073                                    // First block starts with marker
2074                                    result.push(format!("{marker}{content}"));
2075                                    is_first_block = false;
2076                                } else {
2077                                    // Subsequent blocks use expected indent
2078                                    result.push(format!("{expected_indent}{content}"));
2079                                }
2080                                // No blank lines added before or after snippet delimiters
2081                            }
2082                            Block::DivMarker(content) => {
2083                                // Preserve div markers (::: opening or closing) as-is on their own line
2084                                if is_first_block {
2085                                    result.push(format!("{marker}{content}"));
2086                                    is_first_block = false;
2087                                } else {
2088                                    result.push(format!("{expected_indent}{content}"));
2089                                }
2090                            }
2091                            Block::Html {
2092                                lines: html_lines,
2093                                has_preceding_blank: _,
2094                            } => {
2095                                // Preserve HTML blocks exactly as-is with original indentation
2096                                // NOTE: Blank line before HTML block is handled by the previous block
2097
2098                                for (idx, line) in html_lines.iter().enumerate() {
2099                                    if is_first_block && idx == 0 {
2100                                        // First line of first block gets marker
2101                                        result.push(format!("{marker}{line}"));
2102                                        is_first_block = false;
2103                                    } else if line.is_empty() {
2104                                        // Preserve blank lines inside HTML blocks
2105                                        result.push(String::new());
2106                                    } else {
2107                                        // Preserve lines with their original content (already includes indentation)
2108                                        result.push(format!("{expected_indent}{line}"));
2109                                    }
2110                                }
2111
2112                                // Add blank line after HTML block if there's a next block.
2113                                // Only add if not already ending with one (avoids double blanks
2114                                // when the HTML block itself contained a trailing blank line).
2115                                if block_idx < blocks.len() - 1 {
2116                                    let next_block = &blocks[block_idx + 1];
2117                                    let should_add_blank = match next_block {
2118                                        Block::Code {
2119                                            has_preceding_blank, ..
2120                                        } => *has_preceding_blank,
2121                                        Block::Html {
2122                                            has_preceding_blank, ..
2123                                        } => *has_preceding_blank,
2124                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2125                                        _ => true, // For all other blocks, add blank line
2126                                    };
2127                                    if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2128                                    {
2129                                        result.push(String::new());
2130                                    }
2131                                }
2132                            }
2133                            Block::Admonition {
2134                                header,
2135                                header_indent,
2136                                content_lines: admon_lines,
2137                            } => {
2138                                // Reconstruct admonition block with header at original indent
2139                                // and body content reflowed to fit within the line length limit
2140
2141                                // Add blank line before admonition if not first block
2142                                if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2143                                    result.push(String::new());
2144                                }
2145
2146                                // Output the header at its original indent
2147                                let header_indent_str = " ".repeat(*header_indent);
2148                                if is_first_block {
2149                                    result.push(format!(
2150                                        "{marker}{}",
2151                                        " ".repeat(header_indent.saturating_sub(marker_len)) + header
2152                                    ));
2153                                    is_first_block = false;
2154                                } else {
2155                                    result.push(format!("{header_indent_str}{header}"));
2156                                }
2157
2158                                // Derive body indent from the first non-empty content line's
2159                                // stored indent, falling back to header_indent + 4 for
2160                                // empty-body admonitions
2161                                let body_indent = admon_lines
2162                                    .iter()
2163                                    .find(|(content, _)| !content.is_empty())
2164                                    .map(|(_, indent)| *indent)
2165                                    .unwrap_or(header_indent + 4);
2166                                let body_indent_str = " ".repeat(body_indent);
2167
2168                                // Collect body content into paragraphs separated by blank lines
2169                                let mut body_paragraphs: Vec<Vec<String>> = Vec::new();
2170                                let mut current_para: Vec<String> = Vec::new();
2171
2172                                for (content, _orig_indent) in admon_lines {
2173                                    if content.is_empty() {
2174                                        if !current_para.is_empty() {
2175                                            body_paragraphs.push(current_para.clone());
2176                                            current_para.clear();
2177                                        }
2178                                    } else {
2179                                        current_para.push(content.clone());
2180                                    }
2181                                }
2182                                if !current_para.is_empty() {
2183                                    body_paragraphs.push(current_para);
2184                                }
2185
2186                                // Reflow each paragraph in the body
2187                                for paragraph in &body_paragraphs {
2188                                    // Add blank line before each paragraph (including the first, after the header)
2189                                    result.push(String::new());
2190
2191                                    let paragraph_text = paragraph.join(" ").trim().to_string();
2192                                    if paragraph_text.is_empty() {
2193                                        continue;
2194                                    }
2195
2196                                    // Reflow with adjusted line length
2197                                    let admon_reflow_length = if config.line_length.is_unlimited() {
2198                                        usize::MAX
2199                                    } else {
2200                                        config.line_length.get().saturating_sub(body_indent).max(1)
2201                                    };
2202
2203                                    let admon_reflow_options = crate::utils::text_reflow::ReflowOptions {
2204                                        line_length: admon_reflow_length,
2205                                        break_on_sentences: true,
2206                                        preserve_breaks: false,
2207                                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2208                                        semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2209                                        abbreviations: config.abbreviations_for_reflow(),
2210                                        length_mode: self.reflow_length_mode(),
2211                                    };
2212
2213                                    let reflowed =
2214                                        crate::utils::text_reflow::reflow_line(&paragraph_text, &admon_reflow_options);
2215                                    for line in &reflowed {
2216                                        result.push(format!("{body_indent_str}{line}"));
2217                                    }
2218                                }
2219
2220                                // Add blank line after admonition if there's a next block
2221                                if block_idx < blocks.len() - 1 {
2222                                    let next_block = &blocks[block_idx + 1];
2223                                    let should_add_blank = match next_block {
2224                                        Block::Code {
2225                                            has_preceding_blank, ..
2226                                        } => *has_preceding_blank,
2227                                        Block::SnippetLine(_) | Block::DivMarker(_) => false,
2228                                        _ => true,
2229                                    };
2230                                    if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2231                                    {
2232                                        result.push(String::new());
2233                                    }
2234                                }
2235                            }
2236                        }
2237                    }
2238
2239                    let reflowed_text = result.join(line_ending);
2240
2241                    // Preserve trailing newline
2242                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2243                        format!("{reflowed_text}{line_ending}")
2244                    } else {
2245                        reflowed_text
2246                    };
2247
2248                    // Get the original text to compare
2249                    let original_text = &ctx.content[byte_range.clone()];
2250
2251                    // Only generate a warning if the replacement is different from the original
2252                    if original_text != replacement {
2253                        // Generate an appropriate message based on why reflow is needed
2254                        let message = match config.reflow_mode {
2255                            ReflowMode::SentencePerLine => {
2256                                let num_sentences = split_into_sentences(&combined_content).len();
2257                                let num_lines = content_lines.len();
2258                                if num_lines == 1 {
2259                                    // Single line with multiple sentences
2260                                    format!("Line contains {num_sentences} sentences (one sentence per line required)")
2261                                } else {
2262                                    // Multiple lines - could be split sentences or mixed
2263                                    format!(
2264                                        "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
2265                                    )
2266                                }
2267                            }
2268                            ReflowMode::SemanticLineBreaks => {
2269                                let num_sentences = split_into_sentences(&combined_content).len();
2270                                format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
2271                            }
2272                            ReflowMode::Normalize => {
2273                                // Find the longest non-exempt paragraph when joined
2274                                let max_para_length = blocks
2275                                    .iter()
2276                                    .filter_map(|block| {
2277                                        if let Block::Paragraph(para_lines) = block {
2278                                            if para_lines.iter().all(|line| is_exempt_line(line)) {
2279                                                return None;
2280                                            }
2281                                            let joined = para_lines.join(" ");
2282                                            let with_indent = format!("{}{}", " ".repeat(marker_len), joined.trim());
2283                                            Some(self.calculate_effective_length(&with_indent))
2284                                        } else {
2285                                            None
2286                                        }
2287                                    })
2288                                    .max()
2289                                    .unwrap_or(0);
2290                                if max_para_length > config.line_length.get() {
2291                                    format!(
2292                                        "Line length {} exceeds {} characters",
2293                                        max_para_length,
2294                                        config.line_length.get()
2295                                    )
2296                                } else {
2297                                    "Multi-line content can be normalized".to_string()
2298                                }
2299                            }
2300                            ReflowMode::Default => {
2301                                // Report the actual longest non-exempt line, not the combined content
2302                                let max_length = (list_start..i)
2303                                    .filter(|&line_idx| {
2304                                        let line = lines[line_idx];
2305                                        let trimmed = line.trim();
2306                                        !trimmed.is_empty() && !is_exempt_line(line)
2307                                    })
2308                                    .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
2309                                    .max()
2310                                    .unwrap_or(0);
2311                                format!(
2312                                    "Line length {} exceeds {} characters",
2313                                    max_length,
2314                                    config.line_length.get()
2315                                )
2316                            }
2317                        };
2318
2319                        warnings.push(LintWarning {
2320                            rule_name: Some(self.name().to_string()),
2321                            message,
2322                            line: list_start + 1,
2323                            column: 1,
2324                            end_line: end_line + 1,
2325                            end_column: lines[end_line].len() + 1,
2326                            severity: Severity::Warning,
2327                            fix: Some(crate::rule::Fix {
2328                                range: byte_range,
2329                                replacement,
2330                            }),
2331                        });
2332                    }
2333                }
2334                continue;
2335            }
2336
2337            // Found start of a paragraph - collect all lines in it
2338            let paragraph_start = i;
2339            let mut paragraph_lines = vec![lines[i]];
2340            i += 1;
2341
2342            while i < lines.len() {
2343                let next_line = lines[i];
2344                let next_line_num = i + 1;
2345                let next_trimmed = next_line.trim();
2346
2347                // Stop at paragraph boundaries
2348                if next_trimmed.is_empty()
2349                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
2350                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
2351                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
2352                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
2353                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
2354                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
2355                    || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
2356                    || ctx
2357                        .line_info(next_line_num)
2358                        .is_some_and(|info| info.in_mkdocs_container())
2359                    || (next_line_num > 0
2360                        && next_line_num <= ctx.lines.len()
2361                        && ctx.lines[next_line_num - 1].blockquote.is_some())
2362                    || next_trimmed.starts_with('#')
2363                    || TableUtils::is_potential_table_row(next_line)
2364                    || is_list_item(next_trimmed)
2365                    || is_horizontal_rule(next_trimmed)
2366                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
2367                    || is_template_directive_only(next_line)
2368                    || is_standalone_attr_list(next_line)
2369                    || is_snippet_block_delimiter(next_line)
2370                    || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
2371                {
2372                    break;
2373                }
2374
2375                // Check if the previous line ends with a hard break (2+ spaces or backslash)
2376                if i > 0 && has_hard_break(lines[i - 1]) {
2377                    // Don't include lines after hard breaks in the same paragraph
2378                    break;
2379                }
2380
2381                paragraph_lines.push(next_line);
2382                i += 1;
2383            }
2384
2385            // Combine paragraph lines into a single string for processing
2386            // This must be done BEFORE the needs_reflow check for sentence-per-line mode
2387            let paragraph_text = paragraph_lines.join(" ");
2388
2389            // Skip reflowing if this paragraph contains definition list items
2390            // Definition lists are multi-line structures that should not be joined
2391            let contains_definition_list = paragraph_lines
2392                .iter()
2393                .any(|line| crate::utils::is_definition_list_item(line));
2394
2395            if contains_definition_list {
2396                // Don't reflow definition lists - skip this paragraph
2397                i = paragraph_start + paragraph_lines.len();
2398                continue;
2399            }
2400
2401            // Skip reflowing if this paragraph contains MkDocs Snippets markers
2402            // Snippets blocks (-8<- ... -8<-) should be preserved exactly
2403            let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
2404
2405            if contains_snippets {
2406                // Don't reflow Snippets blocks - skip this paragraph
2407                i = paragraph_start + paragraph_lines.len();
2408                continue;
2409            }
2410
2411            // Check if this paragraph needs reflowing
2412            let needs_reflow = match config.reflow_mode {
2413                ReflowMode::Normalize => {
2414                    // In normalize mode, reflow multi-line paragraphs
2415                    paragraph_lines.len() > 1
2416                }
2417                ReflowMode::SentencePerLine => {
2418                    // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
2419                    // Note: we check the joined text because sentences can span multiple lines
2420                    let sentences = split_into_sentences(&paragraph_text);
2421
2422                    // Always reflow if multiple sentences on one line
2423                    if sentences.len() > 1 {
2424                        true
2425                    } else if paragraph_lines.len() > 1 {
2426                        // For single-sentence paragraphs spanning multiple lines:
2427                        // Reflow if they COULD fit on one line (respecting line-length constraint)
2428                        if config.line_length.is_unlimited() {
2429                            // No line-length constraint - always join single sentences
2430                            true
2431                        } else {
2432                            // Only join if it fits within line-length
2433                            let effective_length = self.calculate_effective_length(&paragraph_text);
2434                            effective_length <= config.line_length.get()
2435                        }
2436                    } else {
2437                        false
2438                    }
2439                }
2440                ReflowMode::SemanticLineBreaks => {
2441                    let sentences = split_into_sentences(&paragraph_text);
2442                    // Reflow if multiple sentences, multiple lines, or any line exceeds limit
2443                    sentences.len() > 1
2444                        || paragraph_lines.len() > 1
2445                        || paragraph_lines
2446                            .iter()
2447                            .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2448                }
2449                ReflowMode::Default => {
2450                    // In default mode, only reflow if lines exceed limit
2451                    paragraph_lines
2452                        .iter()
2453                        .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2454                }
2455            };
2456
2457            if needs_reflow {
2458                // Calculate byte range for this paragraph
2459                // Use whole_line_range for each line and combine
2460                let start_range = line_index.whole_line_range(paragraph_start + 1);
2461                let end_line = paragraph_start + paragraph_lines.len() - 1;
2462
2463                // For the last line, we want to preserve any trailing newline
2464                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2465                    // Last line without trailing newline - use line_text_range
2466                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
2467                } else {
2468                    // Not the last line or has trailing newline - use whole_line_range
2469                    line_index.whole_line_range(end_line + 1)
2470                };
2471
2472                let byte_range = start_range.start..end_range.end;
2473
2474                // Check if the paragraph ends with a hard break and what type
2475                let hard_break_type = paragraph_lines.last().and_then(|line| {
2476                    let line = line.strip_suffix('\r').unwrap_or(line);
2477                    if line.ends_with('\\') {
2478                        Some("\\")
2479                    } else if line.ends_with("  ") {
2480                        Some("  ")
2481                    } else {
2482                        None
2483                    }
2484                });
2485
2486                // Reflow the paragraph
2487                // When line_length = 0 (no limit), use a very large value for reflow
2488                let reflow_line_length = if config.line_length.is_unlimited() {
2489                    usize::MAX
2490                } else {
2491                    config.line_length.get()
2492                };
2493                let reflow_options = crate::utils::text_reflow::ReflowOptions {
2494                    line_length: reflow_line_length,
2495                    break_on_sentences: true,
2496                    preserve_breaks: false,
2497                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2498                    semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2499                    abbreviations: config.abbreviations_for_reflow(),
2500                    length_mode: self.reflow_length_mode(),
2501                };
2502                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
2503
2504                // If the original paragraph ended with a hard break, preserve it
2505                // Preserve the original hard break format (backslash or two spaces)
2506                if let Some(break_marker) = hard_break_type
2507                    && !reflowed.is_empty()
2508                {
2509                    let last_idx = reflowed.len() - 1;
2510                    if !has_hard_break(&reflowed[last_idx]) {
2511                        reflowed[last_idx].push_str(break_marker);
2512                    }
2513                }
2514
2515                let reflowed_text = reflowed.join(line_ending);
2516
2517                // Preserve trailing newline if the original paragraph had one
2518                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2519                    format!("{reflowed_text}{line_ending}")
2520                } else {
2521                    reflowed_text
2522                };
2523
2524                // Get the original text to compare
2525                let original_text = &ctx.content[byte_range.clone()];
2526
2527                // Only generate a warning if the replacement is different from the original
2528                if original_text != replacement {
2529                    // Create warning with actual fix
2530                    // In default mode, report the specific line that violates
2531                    // In normalize mode, report the whole paragraph
2532                    // In sentence-per-line mode, report the entire paragraph
2533                    let (warning_line, warning_end_line) = match config.reflow_mode {
2534                        ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
2535                        ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => {
2536                            // Highlight the entire paragraph that needs reformatting
2537                            (paragraph_start + 1, paragraph_start + paragraph_lines.len())
2538                        }
2539                        ReflowMode::Default => {
2540                            // Find the first line that exceeds the limit
2541                            let mut violating_line = paragraph_start;
2542                            for (idx, line) in paragraph_lines.iter().enumerate() {
2543                                if self.calculate_effective_length(line) > config.line_length.get() {
2544                                    violating_line = paragraph_start + idx;
2545                                    break;
2546                                }
2547                            }
2548                            (violating_line + 1, violating_line + 1)
2549                        }
2550                    };
2551
2552                    warnings.push(LintWarning {
2553                        rule_name: Some(self.name().to_string()),
2554                        message: match config.reflow_mode {
2555                            ReflowMode::Normalize => format!(
2556                                "Paragraph could be normalized to use line length of {} characters",
2557                                config.line_length.get()
2558                            ),
2559                            ReflowMode::SentencePerLine => {
2560                                let num_sentences = split_into_sentences(&paragraph_text).len();
2561                                if paragraph_lines.len() == 1 {
2562                                    // Single line with multiple sentences
2563                                    format!("Line contains {num_sentences} sentences (one sentence per line required)")
2564                                } else {
2565                                    let num_lines = paragraph_lines.len();
2566                                    // Multiple lines - could be split sentences or mixed
2567                                    format!("Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)")
2568                                }
2569                            },
2570                            ReflowMode::SemanticLineBreaks => {
2571                                let num_sentences = split_into_sentences(&paragraph_text).len();
2572                                format!(
2573                                    "Paragraph should use semantic line breaks ({num_sentences} sentences)"
2574                                )
2575                            },
2576                            ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
2577                        },
2578                        line: warning_line,
2579                        column: 1,
2580                        end_line: warning_end_line,
2581                        end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
2582                        severity: Severity::Warning,
2583                        fix: Some(crate::rule::Fix {
2584                            range: byte_range,
2585                            replacement,
2586                        }),
2587                    });
2588                }
2589            }
2590        }
2591
2592        warnings
2593    }
2594
2595    /// Calculate string length based on the configured length mode
2596    fn calculate_string_length(&self, s: &str) -> usize {
2597        match self.config.length_mode {
2598            LengthMode::Chars => s.chars().count(),
2599            LengthMode::Visual => s.width(),
2600            LengthMode::Bytes => s.len(),
2601        }
2602    }
2603
2604    /// Calculate effective line length
2605    ///
2606    /// Returns the actual display length of the line using the configured length mode.
2607    fn calculate_effective_length(&self, line: &str) -> usize {
2608        self.calculate_string_length(line)
2609    }
2610
2611    /// Calculate line length with inline link/image URLs removed.
2612    ///
2613    /// For each inline link `[text](url)` or image `![alt](url)` on the line,
2614    /// computes the "savings" from removing the URL portion (keeping only `[text]`
2615    /// or `![alt]`). Returns `effective_length - total_savings`.
2616    ///
2617    /// Handles nested constructs (e.g., `[![img](url)](url)`) by only counting the
2618    /// outermost construct to avoid double-counting.
2619    fn calculate_text_only_length(
2620        &self,
2621        effective_length: usize,
2622        line_number: usize,
2623        ctx: &crate::lint_context::LintContext,
2624    ) -> usize {
2625        let line_range = ctx.line_index.line_content_range(line_number);
2626        let line_byte_end = line_range.end;
2627
2628        // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
2629        let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
2630
2631        for link in &ctx.links {
2632            if link.line != line_number || link.is_reference {
2633                continue;
2634            }
2635            if !matches!(link.link_type, LinkType::Inline) {
2636                continue;
2637            }
2638            // Skip cross-line links
2639            if link.byte_end > line_byte_end {
2640                continue;
2641            }
2642            // `[text]` in configured length mode
2643            let text_only_len = 2 + self.calculate_string_length(&link.text);
2644            constructs.push((link.byte_offset, link.byte_end, text_only_len));
2645        }
2646
2647        for image in &ctx.images {
2648            if image.line != line_number || image.is_reference {
2649                continue;
2650            }
2651            if !matches!(image.link_type, LinkType::Inline) {
2652                continue;
2653            }
2654            // Skip cross-line images
2655            if image.byte_end > line_byte_end {
2656                continue;
2657            }
2658            // `![alt]` in configured length mode
2659            let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
2660            constructs.push((image.byte_offset, image.byte_end, text_only_len));
2661        }
2662
2663        if constructs.is_empty() {
2664            return effective_length;
2665        }
2666
2667        // Sort by byte offset to handle overlapping/nested constructs
2668        constructs.sort_by_key(|&(start, _, _)| start);
2669
2670        let mut total_savings: usize = 0;
2671        let mut last_end: usize = 0;
2672
2673        for (start, end, text_only_len) in &constructs {
2674            // Skip constructs nested inside a previously counted one
2675            if *start < last_end {
2676                continue;
2677            }
2678            // Full construct length in configured length mode
2679            let full_source = &ctx.content[*start..*end];
2680            let full_len = self.calculate_string_length(full_source);
2681            total_savings += full_len.saturating_sub(*text_only_len);
2682            last_end = *end;
2683        }
2684
2685        effective_length.saturating_sub(total_savings)
2686    }
2687}