Skip to main content

rumdl_lib/rules/md013_line_length/
mod.rs

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