Skip to main content

rumdl_lib/utils/
text_reflow.rs

1//! Text reflow utilities for MD013
2//!
3//! This module implements text wrapping/reflow functionality that preserves
4//! Markdown elements like links, emphasis, code spans, etc.
5
6use crate::utils::calculate_indentation_width_default;
7use crate::utils::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11    DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, FOOTNOTE_REF_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12    HUGO_SHORTCODE_REGEX, INLINE_IMAGE_REGEX, INLINE_LINK_FANCY_REGEX, INLINE_MATH_REGEX, LINKED_IMAGE_INLINE_INLINE,
13    LINKED_IMAGE_INLINE_REF, LINKED_IMAGE_REF_INLINE, LINKED_IMAGE_REF_REF, REF_IMAGE_REGEX, REF_LINK_REGEX,
14    SHORTCUT_REF_REGEX, WIKI_LINK_REGEX,
15};
16use crate::utils::sentence_utils::{
17    get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
18    text_ends_with_abbreviation,
19};
20use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
21use std::collections::HashSet;
22use unicode_width::UnicodeWidthStr;
23
24/// Length calculation mode for reflow
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub enum ReflowLengthMode {
27    /// Count Unicode characters (grapheme clusters)
28    Chars,
29    /// Count visual display width (CJK = 2 columns, emoji = 2, etc.)
30    #[default]
31    Visual,
32    /// Count raw bytes
33    Bytes,
34}
35
36/// Calculate the display length of a string based on the length mode
37fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
38    match mode {
39        ReflowLengthMode::Chars => s.chars().count(),
40        ReflowLengthMode::Visual => s.width(),
41        ReflowLengthMode::Bytes => s.len(),
42    }
43}
44
45/// Options for reflowing text
46#[derive(Clone)]
47pub struct ReflowOptions {
48    /// Target line length
49    pub line_length: usize,
50    /// Whether to break on sentence boundaries when possible
51    pub break_on_sentences: bool,
52    /// Whether to preserve existing line breaks in paragraphs
53    pub preserve_breaks: bool,
54    /// Whether to enforce one sentence per line
55    pub sentence_per_line: bool,
56    /// Whether to use semantic line breaks (cascading split strategy)
57    pub semantic_line_breaks: bool,
58    /// Custom abbreviations for sentence detection
59    /// Periods are optional - both "Dr" and "Dr." work the same
60    /// Custom abbreviations are always added to the built-in defaults
61    pub abbreviations: Option<Vec<String>>,
62    /// How to measure string length for line-length comparisons
63    pub length_mode: ReflowLengthMode,
64    /// Whether to treat {#id .class key="value"} as atomic (unsplittable) elements.
65    /// Enabled for MkDocs and Kramdown flavors.
66    pub attr_lists: bool,
67    /// Whether to require uppercase after periods for sentence detection.
68    /// When true (default), only "word. Capital" is a sentence boundary.
69    /// When false, "word. lowercase" is also treated as a sentence boundary.
70    /// Does not affect ! and ? which are always treated as sentence boundaries.
71    pub require_sentence_capital: bool,
72    /// Cap list continuation indent to this value when set.
73    /// Used by mkdocs flavor where continuation is always 4 spaces
74    /// regardless of checkbox markers.
75    pub max_list_continuation_indent: Option<usize>,
76}
77
78impl Default for ReflowOptions {
79    fn default() -> Self {
80        Self {
81            line_length: 80,
82            break_on_sentences: true,
83            preserve_breaks: false,
84            sentence_per_line: false,
85            semantic_line_breaks: false,
86            abbreviations: None,
87            length_mode: ReflowLengthMode::default(),
88            attr_lists: false,
89            require_sentence_capital: true,
90            max_list_continuation_indent: None,
91        }
92    }
93}
94
95/// Build a boolean mask indicating which character positions are inside inline code spans.
96/// Handles single, double, and triple backtick delimiters.
97fn compute_inline_code_mask(text: &str) -> Vec<bool> {
98    let chars: Vec<char> = text.chars().collect();
99    let len = chars.len();
100    let mut mask = vec![false; len];
101    let mut i = 0;
102
103    while i < len {
104        if chars[i] == '`' {
105            // Count opening backticks
106            let open_start = i;
107            let mut backtick_count = 0;
108            while i < len && chars[i] == '`' {
109                backtick_count += 1;
110                i += 1;
111            }
112
113            // Find matching closing backticks (same count)
114            let mut found_close = false;
115            let content_start = i;
116            while i < len {
117                if chars[i] == '`' {
118                    let close_start = i;
119                    let mut close_count = 0;
120                    while i < len && chars[i] == '`' {
121                        close_count += 1;
122                        i += 1;
123                    }
124                    if close_count == backtick_count {
125                        // Mark the content between the delimiters (not the backticks themselves)
126                        for item in mask.iter_mut().take(close_start).skip(content_start) {
127                            *item = true;
128                        }
129                        // Also mark the opening and closing backticks
130                        for item in mask.iter_mut().take(content_start).skip(open_start) {
131                            *item = true;
132                        }
133                        for item in mask.iter_mut().take(i).skip(close_start) {
134                            *item = true;
135                        }
136                        found_close = true;
137                        break;
138                    }
139                } else {
140                    i += 1;
141                }
142            }
143
144            if !found_close {
145                // No matching close — backticks are literal, not code span
146                i = open_start + backtick_count;
147            }
148        } else {
149            i += 1;
150        }
151    }
152
153    mask
154}
155
156/// Detect if a character position is a sentence boundary
157/// Based on the approach from github.com/JoshuaKGoldberg/sentences-per-line
158/// Supports both ASCII punctuation (. ! ?) and CJK punctuation (。 ! ?)
159fn is_sentence_boundary(
160    text: &str,
161    chars: &[char],
162    pos: usize,
163    abbreviations: &HashSet<String>,
164    require_sentence_capital: bool,
165) -> bool {
166    if pos + 1 >= chars.len() {
167        return false;
168    }
169
170    let c = chars[pos];
171    let next_char = chars[pos + 1];
172
173    // Check for CJK sentence-ending punctuation (。, !, ?)
174    // CJK punctuation doesn't require space or uppercase after it
175    if is_cjk_sentence_ending(c) {
176        // Skip any trailing emphasis/strikethrough markers
177        let mut after_punct_pos = pos + 1;
178        while after_punct_pos < chars.len()
179            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
180        {
181            after_punct_pos += 1;
182        }
183
184        // Skip whitespace
185        while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
186            after_punct_pos += 1;
187        }
188
189        // Check if we have more content (any non-whitespace)
190        if after_punct_pos >= chars.len() {
191            return false;
192        }
193
194        // Skip leading emphasis/strikethrough markers
195        while after_punct_pos < chars.len()
196            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
197        {
198            after_punct_pos += 1;
199        }
200
201        if after_punct_pos >= chars.len() {
202            return false;
203        }
204
205        // For CJK, we accept any character as the start of the next sentence
206        // (no uppercase requirement, since CJK doesn't have case)
207        return true;
208    }
209
210    // Check for ASCII sentence-ending punctuation
211    if c != '.' && c != '!' && c != '?' {
212        return false;
213    }
214
215    // Must be followed by space, closing quote, or emphasis/strikethrough marker followed by space
216    let (_space_pos, after_space_pos) = if next_char == ' ' {
217        // Normal case: punctuation followed by space
218        (pos + 1, pos + 2)
219    } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
220        // Sentence ends with quote - check what follows the quote
221        if chars[pos + 2] == ' ' {
222            // Just quote followed by space: 'sentence." '
223            (pos + 2, pos + 3)
224        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
225            // Quote followed by emphasis: 'sentence."* '
226            (pos + 3, pos + 4)
227        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
228            && pos + 4 < chars.len()
229            && chars[pos + 3] == chars[pos + 2]
230            && chars[pos + 4] == ' '
231        {
232            // Quote followed by bold: 'sentence."** '
233            (pos + 4, pos + 5)
234        } else {
235            return false;
236        }
237    } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
238        // Sentence ends with emphasis: "sentence.* " or "sentence._ "
239        (pos + 2, pos + 3)
240    } else if (next_char == '*' || next_char == '_')
241        && pos + 3 < chars.len()
242        && chars[pos + 2] == next_char
243        && chars[pos + 3] == ' '
244    {
245        // Sentence ends with bold: "sentence.** " or "sentence.__ "
246        (pos + 3, pos + 4)
247    } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
248        // Sentence ends with strikethrough: "sentence.~~ "
249        (pos + 3, pos + 4)
250    } else {
251        return false;
252    };
253
254    // Skip all whitespace after the space to find the start of the next sentence
255    let mut next_char_pos = after_space_pos;
256    while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
257        next_char_pos += 1;
258    }
259
260    // Check if we reached the end of the string
261    if next_char_pos >= chars.len() {
262        return false;
263    }
264
265    // Skip leading emphasis/strikethrough markers and opening quotes to find the actual first letter
266    let mut first_letter_pos = next_char_pos;
267    while first_letter_pos < chars.len()
268        && (chars[first_letter_pos] == '*'
269            || chars[first_letter_pos] == '_'
270            || chars[first_letter_pos] == '~'
271            || is_opening_quote(chars[first_letter_pos]))
272    {
273        first_letter_pos += 1;
274    }
275
276    // Check if we reached the end after skipping emphasis
277    if first_letter_pos >= chars.len() {
278        return false;
279    }
280
281    let first_char = chars[first_letter_pos];
282
283    // For ! and ?, sentence boundaries are unambiguous — no uppercase requirement
284    if c == '!' || c == '?' {
285        return true;
286    }
287
288    // Period-specific checks: periods are ambiguous (abbreviations, decimals, initials)
289    // so we apply additional guards before accepting a sentence boundary.
290
291    if pos > 0 {
292        // Check for common abbreviations
293        let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
294        if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
295            return false;
296        }
297
298        // Check for decimal numbers (e.g., "3.14 is pi")
299        if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
300            return false;
301        }
302
303        // Check for single-letter initials (e.g., "J. K. Rowling")
304        // A single uppercase letter before the period preceded by whitespace or start
305        // is likely an initial, not a sentence ending.
306        if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
307            return false;
308        }
309    }
310
311    // In strict mode, require uppercase or CJK to start the next sentence after a period.
312    // In relaxed mode, accept any alphanumeric character.
313    if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
314        return false;
315    }
316
317    true
318}
319
320/// Split text into sentences
321pub fn split_into_sentences(text: &str) -> Vec<String> {
322    split_into_sentences_custom(text, &None)
323}
324
325/// Split text into sentences with custom abbreviations
326pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
327    let abbreviations = get_abbreviations(custom_abbreviations);
328    split_into_sentences_with_set(text, &abbreviations, true)
329}
330
331/// Internal function to split text into sentences with a pre-computed abbreviations set
332/// Use this when calling multiple times in a loop to avoid repeatedly computing the set
333fn split_into_sentences_with_set(
334    text: &str,
335    abbreviations: &HashSet<String>,
336    require_sentence_capital: bool,
337) -> Vec<String> {
338    // Pre-compute which character positions are inside inline code spans
339    let in_code = compute_inline_code_mask(text);
340    // Collect chars once and share the slice with is_sentence_boundary, which
341    // would otherwise re-collect the whole text on every position it checks.
342    let char_vec: Vec<char> = text.chars().collect();
343
344    let mut sentences = Vec::new();
345    let mut current_sentence = String::new();
346    let mut chars = text.chars().peekable();
347    let mut pos = 0;
348
349    while let Some(c) = chars.next() {
350        current_sentence.push(c);
351
352        if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
353            // Consume any trailing emphasis/strikethrough markers and quotes (they belong to the current sentence)
354            while let Some(&next) = chars.peek() {
355                if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
356                    current_sentence.push(chars.next().unwrap());
357                    pos += 1;
358                } else {
359                    break;
360                }
361            }
362
363            // Consume the space after the sentence
364            if chars.peek() == Some(&' ') {
365                chars.next();
366                pos += 1;
367            }
368
369            sentences.push(current_sentence.trim().to_string());
370            current_sentence.clear();
371        }
372
373        pos += 1;
374    }
375
376    // Add any remaining text as the last sentence
377    if !current_sentence.trim().is_empty() {
378        sentences.push(current_sentence.trim().to_string());
379    }
380    sentences
381}
382
383/// Check if a line is a horizontal rule (---, ___, ***)
384fn is_horizontal_rule(line: &str) -> bool {
385    if line.len() < 3 {
386        return false;
387    }
388
389    // Line must consist only of a single marker char (-, _, or *) plus spaces,
390    // with at least 3 markers. Scan chars directly to avoid allocating a Vec.
391    let mut chars = line.chars();
392    let Some(first_char) = chars.next() else {
393        return false;
394    };
395    if first_char != '-' && first_char != '_' && first_char != '*' {
396        return false;
397    }
398
399    let mut non_space_count = 1usize; // first_char is a marker
400    for c in chars {
401        if c == ' ' {
402            continue;
403        }
404        if c != first_char {
405            return false;
406        }
407        non_space_count += 1;
408    }
409    non_space_count >= 3
410}
411
412/// Check if a line is a numbered list item (e.g., "1. ", "10. ")
413fn is_numbered_list_item(line: &str) -> bool {
414    let mut chars = line.chars();
415
416    // Must start with a digit
417    if !chars.next().is_some_and(char::is_numeric) {
418        return false;
419    }
420
421    // Can have more digits
422    while let Some(c) = chars.next() {
423        if c == '.' {
424            // After period, must have a space (consistent with list marker extraction)
425            // "2019." alone is NOT treated as a list item to avoid false positives
426            return chars.next() == Some(' ');
427        }
428        if !c.is_numeric() {
429            return false;
430        }
431    }
432
433    false
434}
435
436/// Check if a trimmed line is an unordered list item (-, *, + followed by space)
437fn is_unordered_list_marker(s: &str) -> bool {
438    matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
439        && !is_horizontal_rule(s)
440        && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
441}
442
443/// Shared structural checks for block boundary detection.
444/// Checks elements that only depend on the trimmed line content.
445fn is_block_boundary_core(trimmed: &str) -> bool {
446    trimmed.is_empty()
447        || trimmed.starts_with('#')
448        || trimmed.starts_with("```")
449        || trimmed.starts_with("~~~")
450        || trimmed.starts_with('>')
451        || (trimmed.starts_with('[') && trimmed.contains("]:"))
452        || is_horizontal_rule(trimmed)
453        || is_unordered_list_marker(trimmed)
454        || is_numbered_list_item(trimmed)
455        || is_definition_list_item(trimmed)
456        || trimmed.starts_with(":::")
457}
458
459/// Check if a trimmed line starts a new structural block element.
460/// Used for paragraph boundary detection in `reflow_markdown()`.
461fn is_block_boundary(trimmed: &str) -> bool {
462    is_block_boundary_core(trimmed) || trimmed.starts_with('|')
463}
464
465/// Check if a line starts a new structural block for paragraph boundary detection
466/// in `reflow_paragraph_at_line()`. Extends the core checks with indented code blocks
467/// (≥4 spaces) and table row detection via `is_potential_table_row`.
468fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
469    is_block_boundary_core(trimmed)
470        || calculate_indentation_width_default(line) >= 4
471        || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
472}
473
474/// Check if a line ends with a hard break (either two spaces or backslash)
475///
476/// CommonMark supports two formats for hard line breaks:
477/// 1. Two or more trailing spaces
478/// 2. A backslash at the end of the line
479fn has_hard_break(line: &str) -> bool {
480    let line = line.strip_suffix('\r').unwrap_or(line);
481    line.ends_with("  ") || line.ends_with('\\')
482}
483
484/// Check if text ends with sentence-terminating punctuation (. ! ?)
485fn ends_with_sentence_punct(text: &str) -> bool {
486    text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
487}
488
489/// Trim trailing whitespace while preserving hard breaks (two trailing spaces or backslash)
490///
491/// Hard breaks in Markdown can be indicated by:
492/// 1. Two trailing spaces before a newline (traditional)
493/// 2. A backslash at the end of the line (mdformat style)
494fn trim_preserving_hard_break(s: &str) -> String {
495    // Strip trailing \r from CRLF line endings first to handle Windows files
496    let s = s.strip_suffix('\r').unwrap_or(s);
497
498    // Check for backslash hard break (mdformat style)
499    if s.ends_with('\\') {
500        // Preserve the backslash exactly as-is
501        return s.to_string();
502    }
503
504    // Check if there are at least 2 trailing spaces (traditional hard break)
505    if s.ends_with("  ") {
506        // Find the position where non-space content ends
507        let content_end = s.trim_end().len();
508        if content_end == 0 {
509            // String is all whitespace
510            return String::new();
511        }
512        // Preserve exactly 2 trailing spaces for hard break
513        format!("{}  ", &s[..content_end])
514    } else {
515        // No hard break, just trim all trailing whitespace
516        s.trim_end().to_string()
517    }
518}
519
520/// Parse markdown elements using the appropriate parser based on options.
521fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
522    if options.attr_lists {
523        parse_markdown_elements_with_attr_lists(text)
524    } else {
525        parse_markdown_elements(text)
526    }
527}
528
529pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
530    // For sentence-per-line mode, always process regardless of length
531    if options.sentence_per_line {
532        let elements = parse_elements(line, options);
533        return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
534    }
535
536    // For semantic line breaks mode, use cascading split strategy
537    if options.semantic_line_breaks {
538        let elements = parse_elements(line, options);
539        return reflow_elements_semantic(&elements, options);
540    }
541
542    // Quick check: if line is already short enough or no wrapping requested, return as-is
543    // line_length = 0 means no wrapping (unlimited line length)
544    if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
545        return vec![line.to_string()];
546    }
547
548    // Parse the markdown to identify elements
549    let elements = parse_elements(line, options);
550
551    // Reflow the elements into lines
552    reflow_elements(&elements, options)
553}
554
555/// Image source in a linked image structure
556#[derive(Debug, Clone)]
557enum LinkedImageSource {
558    /// Inline image URL: ![alt](url)
559    Inline(String),
560    /// Reference image: ![alt][ref]
561    Reference(String),
562}
563
564/// Link target in a linked image structure
565#[derive(Debug, Clone)]
566enum LinkedImageTarget {
567    /// Inline link URL: ](url)
568    Inline(String),
569    /// Reference link: ][ref]
570    Reference(String),
571}
572
573/// Represents a piece of content in the markdown
574#[derive(Debug, Clone)]
575enum Element {
576    /// Plain text that can be wrapped
577    Text(String),
578    /// A complete markdown inline link [text](url)
579    Link { text: String, url: String },
580    /// A complete markdown reference link [text][ref]
581    ReferenceLink { text: String, reference: String },
582    /// A complete markdown empty reference link [text][]
583    EmptyReferenceLink { text: String },
584    /// A complete markdown shortcut reference link [ref]
585    ShortcutReference { reference: String },
586    /// A complete markdown inline image ![alt](url)
587    InlineImage { alt: String, url: String },
588    /// A complete markdown reference image ![alt][ref]
589    ReferenceImage { alt: String, reference: String },
590    /// A complete markdown empty reference image ![alt][]
591    EmptyReferenceImage { alt: String },
592    /// A clickable image badge in any of 4 forms:
593    /// - [![alt](img-url)](link-url)
594    /// - [![alt][img-ref]](link-url)
595    /// - [![alt](img-url)][link-ref]
596    /// - [![alt][img-ref]][link-ref]
597    LinkedImage {
598        alt: String,
599        img_source: LinkedImageSource,
600        link_target: LinkedImageTarget,
601    },
602    /// Footnote reference [^note]
603    FootnoteReference { note: String },
604    /// Strikethrough text ~~text~~
605    Strikethrough(String),
606    /// Wiki-style link [[wiki]] or [[wiki|text]]
607    WikiLink(String),
608    /// Inline math $math$
609    InlineMath(String),
610    /// Display math $$math$$
611    DisplayMath(String),
612    /// Emoji shortcode :emoji:
613    EmojiShortcode(String),
614    /// Autolink <https://...> or <mailto:...> or <user@domain.com>
615    Autolink(String),
616    /// HTML tag <tag> or </tag> or <tag/>
617    HtmlTag(String),
618    /// HTML entity &nbsp; or &#123;
619    HtmlEntity(String),
620    /// Hugo/Go template shortcode {{< ... >}} or {{% ... %}}
621    HugoShortcode(String),
622    /// MkDocs/kramdown attribute list {#id .class key="value"}
623    AttrList(String),
624    /// Inline code `code`
625    Code(String),
626    /// Bold text **text** or __text__
627    Bold {
628        content: String,
629        /// True if underscore markers (__), false for asterisks (**)
630        underscore: bool,
631    },
632    /// Italic text *text* or _text_
633    Italic {
634        content: String,
635        /// True if underscore marker (_), false for asterisk (*)
636        underscore: bool,
637    },
638}
639
640impl std::fmt::Display for Element {
641    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642        match self {
643            Element::Text(s) => write!(f, "{s}"),
644            Element::Link { text, url } => write!(f, "[{text}]({url})"),
645            Element::ReferenceLink { text, reference } => write!(f, "[{text}][{reference}]"),
646            Element::EmptyReferenceLink { text } => write!(f, "[{text}][]"),
647            Element::ShortcutReference { reference } => write!(f, "[{reference}]"),
648            Element::InlineImage { alt, url } => write!(f, "![{alt}]({url})"),
649            Element::ReferenceImage { alt, reference } => write!(f, "![{alt}][{reference}]"),
650            Element::EmptyReferenceImage { alt } => write!(f, "![{alt}][]"),
651            Element::LinkedImage {
652                alt,
653                img_source,
654                link_target,
655            } => {
656                // Build the image part: ![alt](url) or ![alt][ref]
657                let img_part = match img_source {
658                    LinkedImageSource::Inline(url) => format!("![{alt}]({url})"),
659                    LinkedImageSource::Reference(r) => format!("![{alt}][{r}]"),
660                };
661                // Build the link part: (url) or [ref]
662                match link_target {
663                    LinkedImageTarget::Inline(url) => write!(f, "[{img_part}]({url})"),
664                    LinkedImageTarget::Reference(r) => write!(f, "[{img_part}][{r}]"),
665                }
666            }
667            Element::FootnoteReference { note } => write!(f, "[^{note}]"),
668            Element::Strikethrough(s) => write!(f, "~~{s}~~"),
669            Element::WikiLink(s) => write!(f, "[[{s}]]"),
670            Element::InlineMath(s) => write!(f, "${s}$"),
671            Element::DisplayMath(s) => write!(f, "$${s}$$"),
672            Element::EmojiShortcode(s) => write!(f, ":{s}:"),
673            Element::Autolink(s) => write!(f, "{s}"),
674            Element::HtmlTag(s) => write!(f, "{s}"),
675            Element::HtmlEntity(s) => write!(f, "{s}"),
676            Element::HugoShortcode(s) => write!(f, "{s}"),
677            Element::AttrList(s) => write!(f, "{s}"),
678            Element::Code(s) => write!(f, "`{s}`"),
679            Element::Bold { content, underscore } => {
680                if *underscore {
681                    write!(f, "__{content}__")
682                } else {
683                    write!(f, "**{content}**")
684                }
685            }
686            Element::Italic { content, underscore } => {
687                if *underscore {
688                    write!(f, "_{content}_")
689                } else {
690                    write!(f, "*{content}*")
691                }
692            }
693        }
694    }
695}
696
697/// An emphasis or formatting span parsed by pulldown-cmark
698#[derive(Debug, Clone)]
699struct EmphasisSpan {
700    /// Byte offset where the emphasis starts (including markers)
701    start: usize,
702    /// Byte offset where the emphasis ends (after closing markers)
703    end: usize,
704    /// The content inside the emphasis markers
705    content: String,
706    /// Whether this is strong (bold) emphasis
707    is_strong: bool,
708    /// Whether this is strikethrough (~~text~~)
709    is_strikethrough: bool,
710    /// Whether the original used underscore markers (for emphasis only)
711    uses_underscore: bool,
712}
713
714/// Extract emphasis and strikethrough spans from text using pulldown-cmark
715///
716/// This provides CommonMark-compliant emphasis parsing, correctly handling:
717/// - Nested emphasis like `*text **bold** more*`
718/// - Left/right flanking delimiter rules
719/// - Underscore vs asterisk markers
720/// - GFM strikethrough (~~text~~)
721///
722/// Returns spans sorted by start position.
723fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
724    let mut spans = Vec::new();
725    let mut options = Options::empty();
726    options.insert(Options::ENABLE_STRIKETHROUGH);
727
728    // Stacks to track nested formatting with their start positions
729    let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); // (start_byte, uses_underscore)
730    let mut strong_stack: Vec<(usize, bool)> = Vec::new();
731    let mut strikethrough_stack: Vec<usize> = Vec::new();
732
733    let parser = Parser::new_ext(text, options).into_offset_iter();
734
735    for (event, range) in parser {
736        match event {
737            Event::Start(Tag::Emphasis) => {
738                // Check if this uses underscore by looking at the original text
739                let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
740                emphasis_stack.push((range.start, uses_underscore));
741            }
742            Event::End(TagEnd::Emphasis) => {
743                if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
744                    // Extract content between the markers (1 char marker on each side)
745                    let content_start = start_byte + 1;
746                    let content_end = range.end - 1;
747                    if content_end > content_start
748                        && let Some(content) = text.get(content_start..content_end)
749                    {
750                        spans.push(EmphasisSpan {
751                            start: start_byte,
752                            end: range.end,
753                            content: content.to_string(),
754                            is_strong: false,
755                            is_strikethrough: false,
756                            uses_underscore,
757                        });
758                    }
759                }
760            }
761            Event::Start(Tag::Strong) => {
762                // Check if this uses underscore by looking at the original text
763                let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
764                strong_stack.push((range.start, uses_underscore));
765            }
766            Event::End(TagEnd::Strong) => {
767                if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
768                    // Extract content between the markers (2 char marker on each side)
769                    let content_start = start_byte + 2;
770                    let content_end = range.end - 2;
771                    if content_end > content_start
772                        && let Some(content) = text.get(content_start..content_end)
773                    {
774                        spans.push(EmphasisSpan {
775                            start: start_byte,
776                            end: range.end,
777                            content: content.to_string(),
778                            is_strong: true,
779                            is_strikethrough: false,
780                            uses_underscore,
781                        });
782                    }
783                }
784            }
785            Event::Start(Tag::Strikethrough) => {
786                strikethrough_stack.push(range.start);
787            }
788            Event::End(TagEnd::Strikethrough) => {
789                if let Some(start_byte) = strikethrough_stack.pop() {
790                    // Extract content between the ~~ markers (2 char marker on each side)
791                    let content_start = start_byte + 2;
792                    let content_end = range.end - 2;
793                    if content_end > content_start
794                        && let Some(content) = text.get(content_start..content_end)
795                    {
796                        spans.push(EmphasisSpan {
797                            start: start_byte,
798                            end: range.end,
799                            content: content.to_string(),
800                            is_strong: false,
801                            is_strikethrough: true,
802                            uses_underscore: false,
803                        });
804                    }
805                }
806            }
807            _ => {}
808        }
809    }
810
811    // Sort by start position
812    spans.sort_by_key(|s| s.start);
813    spans
814}
815
816/// Parse markdown elements from text preserving the raw syntax
817///
818/// Detection order is critical:
819/// 1. Linked images [![alt](img)](link) - must be detected first as atomic units
820/// 2. Inline images ![alt](url) - before links to handle ! prefix
821/// 3. Reference images ![alt][ref] - before reference links
822/// 4. Inline links [text](url) - before reference links
823/// 5. Reference links [text][ref] - before shortcut references
824/// 6. Shortcut reference links [ref] - detected last to avoid false positives
825/// 7. Other elements (code, bold, italic, etc.) - processed normally
826fn parse_markdown_elements(text: &str) -> Vec<Element> {
827    parse_markdown_elements_inner(text, false)
828}
829
830fn parse_markdown_elements_with_attr_lists(text: &str) -> Vec<Element> {
831    parse_markdown_elements_inner(text, true)
832}
833
834fn parse_markdown_elements_inner(text: &str, attr_lists: bool) -> Vec<Element> {
835    let mut elements = Vec::new();
836    let mut remaining = text;
837
838    // Pre-extract emphasis spans using pulldown-cmark for CommonMark-compliant parsing
839    let emphasis_spans = extract_emphasis_spans(text);
840
841    while !remaining.is_empty() {
842        // Calculate current byte offset in original text
843        let current_offset = text.len() - remaining.len();
844        // Find the earliest occurrence of any markdown pattern
845        // Store (start, end, pattern_name) to unify standard Regex and FancyRegex match results
846        let mut earliest_match: Option<(usize, usize, &str)> = None;
847
848        // Check for linked images FIRST (all 4 variants)
849        // Quick literal check: only run expensive regexes if we might have a linked image
850        // Pattern starts with "[!" so check for that first
851        if remaining.contains("[!") {
852            // Pattern 1: [![alt](img)](link) - inline image in inline link
853            if let Some(m) = LINKED_IMAGE_INLINE_INLINE.find(remaining)
854                && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
855            {
856                earliest_match = Some((m.start(), m.end(), "linked_image_ii"));
857            }
858
859            // Pattern 2: [![alt][ref]](link) - reference image in inline link
860            if let Some(m) = LINKED_IMAGE_REF_INLINE.find(remaining)
861                && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
862            {
863                earliest_match = Some((m.start(), m.end(), "linked_image_ri"));
864            }
865
866            // Pattern 3: [![alt](img)][ref] - inline image in reference link
867            if let Some(m) = LINKED_IMAGE_INLINE_REF.find(remaining)
868                && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
869            {
870                earliest_match = Some((m.start(), m.end(), "linked_image_ir"));
871            }
872
873            // Pattern 4: [![alt][ref]][ref] - reference image in reference link
874            if let Some(m) = LINKED_IMAGE_REF_REF.find(remaining)
875                && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
876            {
877                earliest_match = Some((m.start(), m.end(), "linked_image_rr"));
878            }
879        }
880
881        // Check for images (they start with ! so should be detected before links)
882        // Inline images - ![alt](url)
883        if let Some(m) = INLINE_IMAGE_REGEX.find(remaining)
884            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
885        {
886            earliest_match = Some((m.start(), m.end(), "inline_image"));
887        }
888
889        // Reference images - ![alt][ref]
890        if let Some(m) = REF_IMAGE_REGEX.find(remaining)
891            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
892        {
893            earliest_match = Some((m.start(), m.end(), "ref_image"));
894        }
895
896        // Check for footnote references - [^note]
897        if let Some(m) = FOOTNOTE_REF_REGEX.find(remaining)
898            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
899        {
900            earliest_match = Some((m.start(), m.end(), "footnote_ref"));
901        }
902
903        // Check for inline links - [text](url)
904        if let Ok(Some(m)) = INLINE_LINK_FANCY_REGEX.find(remaining)
905            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
906        {
907            earliest_match = Some((m.start(), m.end(), "inline_link"));
908        }
909
910        // Check for reference links - [text][ref]
911        if let Ok(Some(m)) = REF_LINK_REGEX.find(remaining)
912            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
913        {
914            earliest_match = Some((m.start(), m.end(), "ref_link"));
915        }
916
917        // Check for shortcut reference links - [ref]
918        // Only check if we haven't found an earlier pattern that would conflict
919        if let Ok(Some(m)) = SHORTCUT_REF_REGEX.find(remaining)
920            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
921        {
922            earliest_match = Some((m.start(), m.end(), "shortcut_ref"));
923        }
924
925        // Check for wiki-style links - [[wiki]]
926        if let Some(m) = WIKI_LINK_REGEX.find(remaining)
927            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
928        {
929            earliest_match = Some((m.start(), m.end(), "wiki_link"));
930        }
931
932        // Check for display math first (before inline) - $$math$$
933        if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
934            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
935        {
936            earliest_match = Some((m.start(), m.end(), "display_math"));
937        }
938
939        // Check for inline math - $math$
940        if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
941            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
942        {
943            earliest_match = Some((m.start(), m.end(), "inline_math"));
944        }
945
946        // Note: Strikethrough is now handled by pulldown-cmark in extract_emphasis_spans
947
948        // Check for emoji shortcodes - :emoji:
949        if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
950            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
951        {
952            earliest_match = Some((m.start(), m.end(), "emoji"));
953        }
954
955        // Check for HTML entities - &nbsp; etc
956        if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
957            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
958        {
959            earliest_match = Some((m.start(), m.end(), "html_entity"));
960        }
961
962        // Check for Hugo shortcodes - {{< ... >}} or {{% ... %}}
963        // Must be checked before other patterns to avoid false sentence breaks
964        if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
965            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
966        {
967            earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
968        }
969
970        // Check for HTML tags - <tag> </tag> <tag/>
971        // But exclude autolinks like <https://...> or <mailto:...> or email autolinks <user@domain.com>
972        if let Some(m) = HTML_TAG_PATTERN.find(remaining)
973            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
974        {
975            // Check if this is an autolink (starts with protocol or mailto:)
976            let matched_text = &remaining[m.start()..m.end()];
977            let is_url_autolink = matched_text.starts_with("<http://")
978                || matched_text.starts_with("<https://")
979                || matched_text.starts_with("<mailto:")
980                || matched_text.starts_with("<ftp://")
981                || matched_text.starts_with("<ftps://");
982
983            // Check if this is an email autolink (per CommonMark spec: <local@domain.tld>)
984            // Use centralized EMAIL_PATTERN for consistency with MD034 and other rules
985            let is_email_autolink = {
986                let content = matched_text.trim_start_matches('<').trim_end_matches('>');
987                EMAIL_PATTERN.is_match(content)
988            };
989
990            if is_url_autolink || is_email_autolink {
991                earliest_match = Some((m.start(), m.end(), "autolink"));
992            } else {
993                earliest_match = Some((m.start(), m.end(), "html_tag"));
994            }
995        }
996
997        // Find earliest non-link special characters
998        let mut next_special = remaining.len();
999        let mut special_type = "";
1000        let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1001        let mut attr_list_len: usize = 0;
1002
1003        // Check for code spans (not handled by pulldown-cmark in this context)
1004        if let Some(pos) = remaining.find('`')
1005            && pos < next_special
1006        {
1007            next_special = pos;
1008            special_type = "code";
1009        }
1010
1011        // Check for MkDocs/kramdown attr lists - {#id .class key="value"}
1012        if attr_lists
1013            && let Some(pos) = remaining.find('{')
1014            && pos < next_special
1015            && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1016            && m.start() == 0
1017        {
1018            next_special = pos;
1019            special_type = "attr_list";
1020            attr_list_len = m.end();
1021        }
1022
1023        // Check for emphasis using pulldown-cmark's pre-extracted spans
1024        // Find the earliest emphasis span that starts within remaining text
1025        for span in &emphasis_spans {
1026            if span.start >= current_offset && span.start < current_offset + remaining.len() {
1027                let pos_in_remaining = span.start - current_offset;
1028                if pos_in_remaining < next_special {
1029                    next_special = pos_in_remaining;
1030                    special_type = "pulldown_emphasis";
1031                    pulldown_emphasis = Some(span);
1032                }
1033                break; // Spans are sorted by start position, so first match is earliest
1034            }
1035        }
1036
1037        // Determine which pattern to process first
1038        let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1039            pos < next_special
1040        } else {
1041            false
1042        };
1043
1044        if should_process_markdown_link {
1045            let (pos, match_end, pattern_type) = earliest_match.unwrap();
1046
1047            // Add any text before the match
1048            if pos > 0 {
1049                elements.push(Element::Text(remaining[..pos].to_string()));
1050            }
1051
1052            // Process the matched pattern
1053            match pattern_type {
1054                // Pattern 1: [![alt](img)](link) - inline image in inline link
1055                "linked_image_ii" => {
1056                    if let Some(caps) = LINKED_IMAGE_INLINE_INLINE.captures(remaining) {
1057                        let alt = caps.get(1).map_or("", |m| m.as_str());
1058                        let img_url = caps.get(2).map_or("", |m| m.as_str());
1059                        let link_url = caps.get(3).map_or("", |m| m.as_str());
1060                        elements.push(Element::LinkedImage {
1061                            alt: alt.to_string(),
1062                            img_source: LinkedImageSource::Inline(img_url.to_string()),
1063                            link_target: LinkedImageTarget::Inline(link_url.to_string()),
1064                        });
1065                        remaining = &remaining[match_end..];
1066                    } else {
1067                        elements.push(Element::Text("[".to_string()));
1068                        remaining = &remaining[1..];
1069                    }
1070                }
1071                // Pattern 2: [![alt][ref]](link) - reference image in inline link
1072                "linked_image_ri" => {
1073                    if let Some(caps) = LINKED_IMAGE_REF_INLINE.captures(remaining) {
1074                        let alt = caps.get(1).map_or("", |m| m.as_str());
1075                        let img_ref = caps.get(2).map_or("", |m| m.as_str());
1076                        let link_url = caps.get(3).map_or("", |m| m.as_str());
1077                        elements.push(Element::LinkedImage {
1078                            alt: alt.to_string(),
1079                            img_source: LinkedImageSource::Reference(img_ref.to_string()),
1080                            link_target: LinkedImageTarget::Inline(link_url.to_string()),
1081                        });
1082                        remaining = &remaining[match_end..];
1083                    } else {
1084                        elements.push(Element::Text("[".to_string()));
1085                        remaining = &remaining[1..];
1086                    }
1087                }
1088                // Pattern 3: [![alt](img)][ref] - inline image in reference link
1089                "linked_image_ir" => {
1090                    if let Some(caps) = LINKED_IMAGE_INLINE_REF.captures(remaining) {
1091                        let alt = caps.get(1).map_or("", |m| m.as_str());
1092                        let img_url = caps.get(2).map_or("", |m| m.as_str());
1093                        let link_ref = caps.get(3).map_or("", |m| m.as_str());
1094                        elements.push(Element::LinkedImage {
1095                            alt: alt.to_string(),
1096                            img_source: LinkedImageSource::Inline(img_url.to_string()),
1097                            link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1098                        });
1099                        remaining = &remaining[match_end..];
1100                    } else {
1101                        elements.push(Element::Text("[".to_string()));
1102                        remaining = &remaining[1..];
1103                    }
1104                }
1105                // Pattern 4: [![alt][ref]][ref] - reference image in reference link
1106                "linked_image_rr" => {
1107                    if let Some(caps) = LINKED_IMAGE_REF_REF.captures(remaining) {
1108                        let alt = caps.get(1).map_or("", |m| m.as_str());
1109                        let img_ref = caps.get(2).map_or("", |m| m.as_str());
1110                        let link_ref = caps.get(3).map_or("", |m| m.as_str());
1111                        elements.push(Element::LinkedImage {
1112                            alt: alt.to_string(),
1113                            img_source: LinkedImageSource::Reference(img_ref.to_string()),
1114                            link_target: LinkedImageTarget::Reference(link_ref.to_string()),
1115                        });
1116                        remaining = &remaining[match_end..];
1117                    } else {
1118                        elements.push(Element::Text("[".to_string()));
1119                        remaining = &remaining[1..];
1120                    }
1121                }
1122                "inline_image" => {
1123                    if let Some(caps) = INLINE_IMAGE_REGEX.captures(remaining) {
1124                        let alt = caps.get(1).map_or("", |m| m.as_str());
1125                        let url = caps.get(2).map_or("", |m| m.as_str());
1126                        elements.push(Element::InlineImage {
1127                            alt: alt.to_string(),
1128                            url: url.to_string(),
1129                        });
1130                        remaining = &remaining[match_end..];
1131                    } else {
1132                        elements.push(Element::Text("!".to_string()));
1133                        remaining = &remaining[1..];
1134                    }
1135                }
1136                "ref_image" => {
1137                    if let Some(caps) = REF_IMAGE_REGEX.captures(remaining) {
1138                        let alt = caps.get(1).map_or("", |m| m.as_str());
1139                        let reference = caps.get(2).map_or("", |m| m.as_str());
1140
1141                        if reference.is_empty() {
1142                            elements.push(Element::EmptyReferenceImage { alt: alt.to_string() });
1143                        } else {
1144                            elements.push(Element::ReferenceImage {
1145                                alt: alt.to_string(),
1146                                reference: reference.to_string(),
1147                            });
1148                        }
1149                        remaining = &remaining[match_end..];
1150                    } else {
1151                        elements.push(Element::Text("!".to_string()));
1152                        remaining = &remaining[1..];
1153                    }
1154                }
1155                "footnote_ref" => {
1156                    if let Some(caps) = FOOTNOTE_REF_REGEX.captures(remaining) {
1157                        let note = caps.get(1).map_or("", |m| m.as_str());
1158                        elements.push(Element::FootnoteReference { note: note.to_string() });
1159                        remaining = &remaining[match_end..];
1160                    } else {
1161                        elements.push(Element::Text("[".to_string()));
1162                        remaining = &remaining[1..];
1163                    }
1164                }
1165                "inline_link" => {
1166                    if let Ok(Some(caps)) = INLINE_LINK_FANCY_REGEX.captures(remaining) {
1167                        let text = caps.get(1).map_or("", |m| m.as_str());
1168                        let url = caps.get(2).map_or("", |m| m.as_str());
1169                        elements.push(Element::Link {
1170                            text: text.to_string(),
1171                            url: url.to_string(),
1172                        });
1173                        remaining = &remaining[match_end..];
1174                    } else {
1175                        // Fallback - shouldn't happen
1176                        elements.push(Element::Text("[".to_string()));
1177                        remaining = &remaining[1..];
1178                    }
1179                }
1180                "ref_link" => {
1181                    if let Ok(Some(caps)) = REF_LINK_REGEX.captures(remaining) {
1182                        let text = caps.get(1).map_or("", |m| m.as_str());
1183                        let reference = caps.get(2).map_or("", |m| m.as_str());
1184
1185                        if reference.is_empty() {
1186                            // Empty reference link [text][]
1187                            elements.push(Element::EmptyReferenceLink { text: text.to_string() });
1188                        } else {
1189                            // Regular reference link [text][ref]
1190                            elements.push(Element::ReferenceLink {
1191                                text: text.to_string(),
1192                                reference: reference.to_string(),
1193                            });
1194                        }
1195                        remaining = &remaining[match_end..];
1196                    } else {
1197                        // Fallback - shouldn't happen
1198                        elements.push(Element::Text("[".to_string()));
1199                        remaining = &remaining[1..];
1200                    }
1201                }
1202                "shortcut_ref" => {
1203                    if let Ok(Some(caps)) = SHORTCUT_REF_REGEX.captures(remaining) {
1204                        let reference = caps.get(1).map_or("", |m| m.as_str());
1205                        elements.push(Element::ShortcutReference {
1206                            reference: reference.to_string(),
1207                        });
1208                        remaining = &remaining[match_end..];
1209                    } else {
1210                        // Fallback - shouldn't happen
1211                        elements.push(Element::Text("[".to_string()));
1212                        remaining = &remaining[1..];
1213                    }
1214                }
1215                "wiki_link" => {
1216                    if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1217                        let content = caps.get(1).map_or("", |m| m.as_str());
1218                        elements.push(Element::WikiLink(content.to_string()));
1219                        remaining = &remaining[match_end..];
1220                    } else {
1221                        elements.push(Element::Text("[[".to_string()));
1222                        remaining = &remaining[2..];
1223                    }
1224                }
1225                "display_math" => {
1226                    if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1227                        let math = caps.get(1).map_or("", |m| m.as_str());
1228                        elements.push(Element::DisplayMath(math.to_string()));
1229                        remaining = &remaining[match_end..];
1230                    } else {
1231                        elements.push(Element::Text("$$".to_string()));
1232                        remaining = &remaining[2..];
1233                    }
1234                }
1235                "inline_math" => {
1236                    if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1237                        let math = caps.get(1).map_or("", |m| m.as_str());
1238                        elements.push(Element::InlineMath(math.to_string()));
1239                        remaining = &remaining[match_end..];
1240                    } else {
1241                        elements.push(Element::Text("$".to_string()));
1242                        remaining = &remaining[1..];
1243                    }
1244                }
1245                // Note: "strikethrough" case removed - now handled by pulldown-cmark
1246                "emoji" => {
1247                    if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1248                        let emoji = caps.get(1).map_or("", |m| m.as_str());
1249                        elements.push(Element::EmojiShortcode(emoji.to_string()));
1250                        remaining = &remaining[match_end..];
1251                    } else {
1252                        elements.push(Element::Text(":".to_string()));
1253                        remaining = &remaining[1..];
1254                    }
1255                }
1256                "html_entity" => {
1257                    // HTML entities are captured whole
1258                    elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1259                    remaining = &remaining[match_end..];
1260                }
1261                "hugo_shortcode" => {
1262                    // Hugo shortcodes are atomic elements - preserve them exactly
1263                    elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1264                    remaining = &remaining[match_end..];
1265                }
1266                "autolink" => {
1267                    // Autolinks are atomic elements - preserve them exactly
1268                    elements.push(Element::Autolink(remaining[pos..match_end].to_string()));
1269                    remaining = &remaining[match_end..];
1270                }
1271                "html_tag" => {
1272                    // HTML tags are captured whole
1273                    elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1274                    remaining = &remaining[match_end..];
1275                }
1276                _ => {
1277                    // Unknown pattern, treat as text
1278                    elements.push(Element::Text("[".to_string()));
1279                    remaining = &remaining[1..];
1280                }
1281            }
1282        } else {
1283            // Process non-link special characters
1284
1285            // Add any text before the special character
1286            if next_special > 0 && next_special < remaining.len() {
1287                elements.push(Element::Text(remaining[..next_special].to_string()));
1288                remaining = &remaining[next_special..];
1289            }
1290
1291            // Process the special element
1292            match special_type {
1293                "code" => {
1294                    // Find end of code
1295                    if let Some(code_end) = remaining[1..].find('`') {
1296                        let code = &remaining[1..=code_end];
1297                        elements.push(Element::Code(code.to_string()));
1298                        remaining = &remaining[1 + code_end + 1..];
1299                    } else {
1300                        // No closing backtick, treat as text
1301                        elements.push(Element::Text(remaining.to_string()));
1302                        break;
1303                    }
1304                }
1305                "attr_list" => {
1306                    elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1307                    remaining = &remaining[attr_list_len..];
1308                }
1309                "pulldown_emphasis" => {
1310                    // Use pre-extracted emphasis/strikethrough span from pulldown-cmark
1311                    if let Some(span) = pulldown_emphasis {
1312                        let span_len = span.end - span.start;
1313                        if span.is_strikethrough {
1314                            elements.push(Element::Strikethrough(span.content.clone()));
1315                        } else if span.is_strong {
1316                            elements.push(Element::Bold {
1317                                content: span.content.clone(),
1318                                underscore: span.uses_underscore,
1319                            });
1320                        } else {
1321                            elements.push(Element::Italic {
1322                                content: span.content.clone(),
1323                                underscore: span.uses_underscore,
1324                            });
1325                        }
1326                        remaining = &remaining[span_len..];
1327                    } else {
1328                        // Fallback - shouldn't happen
1329                        elements.push(Element::Text(remaining[..1].to_string()));
1330                        remaining = &remaining[1..];
1331                    }
1332                }
1333                _ => {
1334                    // No special elements found, add all remaining text
1335                    elements.push(Element::Text(remaining.to_string()));
1336                    break;
1337                }
1338            }
1339        }
1340    }
1341
1342    elements
1343}
1344
1345fn should_insert_space_before_join(current: &str) -> bool {
1346    !current.is_empty()
1347        && !current.ends_with(' ')
1348        && !current.ends_with('(')
1349        && !current.ends_with('[')
1350        && !current.ends_with('-')
1351}
1352
1353/// Reflow elements for sentence-per-line mode
1354fn reflow_elements_sentence_per_line(
1355    elements: &[Element],
1356    custom_abbreviations: &Option<Vec<String>>,
1357    require_sentence_capital: bool,
1358) -> Vec<String> {
1359    let abbreviations = get_abbreviations(custom_abbreviations);
1360    let mut lines = Vec::new();
1361    let mut current_line = String::new();
1362
1363    for (idx, element) in elements.iter().enumerate() {
1364        let element_str = format!("{element}");
1365
1366        // For text elements, split into sentences
1367        if let Element::Text(text) = element {
1368            // Simply append text - it already has correct spacing from tokenization
1369            let combined = format!("{current_line}{text}");
1370            // Use the pre-computed abbreviations set to avoid redundant computation
1371            let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1372
1373            if sentences.len() > 1 {
1374                // We found sentence boundaries
1375                for (i, sentence) in sentences.iter().enumerate() {
1376                    if i == 0 {
1377                        // First sentence might continue from previous elements
1378                        // But check if it ends with an abbreviation
1379                        let trimmed = sentence.trim();
1380
1381                        if text_ends_with_abbreviation(trimmed, &abbreviations) {
1382                            // Don't emit yet - this sentence ends with abbreviation, continue accumulating
1383                            current_line.clone_from(sentence);
1384                        } else {
1385                            // Normal case - emit the first sentence
1386                            lines.push(sentence.clone());
1387                            current_line.clear();
1388                        }
1389                    } else if i == sentences.len() - 1 {
1390                        // Last sentence: check if it's complete or incomplete
1391                        let trimmed = sentence.trim();
1392                        let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1393
1394                        if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1395                            // Complete sentence - emit it immediately
1396                            lines.push(sentence.clone());
1397                            current_line.clear();
1398                        } else {
1399                            // Incomplete sentence - save for next iteration
1400                            current_line.clone_from(sentence);
1401                        }
1402                    } else {
1403                        // Complete sentences in the middle
1404                        lines.push(sentence.clone());
1405                    }
1406                }
1407            } else {
1408                // Single sentence - check if it's complete
1409                let trimmed = combined.trim();
1410
1411                // If the combined result is only whitespace, don't accumulate it.
1412                // This prevents leading spaces on subsequent elements when lines
1413                // are joined with spaces during reflow iteration.
1414                if trimmed.is_empty() {
1415                    continue;
1416                }
1417
1418                let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1419
1420                if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1421                    // Complete single sentence - emit it
1422                    lines.push(trimmed.to_string());
1423                    current_line.clear();
1424                } else {
1425                    // Incomplete sentence - continue accumulating
1426                    current_line = combined;
1427                }
1428            }
1429        } else if let Element::Italic { content, underscore } = element {
1430            // Handle italic elements - may contain multiple sentences that need continuation
1431            let marker = if *underscore { "_" } else { "*" };
1432            handle_emphasis_sentence_split(
1433                content,
1434                marker,
1435                &abbreviations,
1436                require_sentence_capital,
1437                &mut current_line,
1438                &mut lines,
1439            );
1440        } else if let Element::Bold { content, underscore } = element {
1441            // Handle bold elements - may contain multiple sentences that need continuation
1442            let marker = if *underscore { "__" } else { "**" };
1443            handle_emphasis_sentence_split(
1444                content,
1445                marker,
1446                &abbreviations,
1447                require_sentence_capital,
1448                &mut current_line,
1449                &mut lines,
1450            );
1451        } else if let Element::Strikethrough(content) = element {
1452            // Handle strikethrough elements - may contain multiple sentences that need continuation
1453            handle_emphasis_sentence_split(
1454                content,
1455                "~~",
1456                &abbreviations,
1457                require_sentence_capital,
1458                &mut current_line,
1459                &mut lines,
1460            );
1461        } else {
1462            // Non-text, non-emphasis elements (Code, Links, etc.)
1463            // Check if this element is adjacent to the preceding text (no space between)
1464            let is_adjacent = if idx > 0 {
1465                match &elements[idx - 1] {
1466                    Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1467                    _ => true,
1468                }
1469            } else {
1470                false
1471            };
1472
1473            // Add space before element if needed, but not for adjacent elements
1474            if !is_adjacent && should_insert_space_before_join(&current_line) {
1475                current_line.push(' ');
1476            }
1477            current_line.push_str(&element_str);
1478        }
1479    }
1480
1481    // Add any remaining content
1482    if !current_line.is_empty() {
1483        lines.push(current_line.trim().to_string());
1484    }
1485    lines
1486}
1487
1488/// Handle splitting emphasis content at sentence boundaries while preserving markers
1489fn handle_emphasis_sentence_split(
1490    content: &str,
1491    marker: &str,
1492    abbreviations: &HashSet<String>,
1493    require_sentence_capital: bool,
1494    current_line: &mut String,
1495    lines: &mut Vec<String>,
1496) {
1497    // Split the emphasis content into sentences
1498    let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1499
1500    if sentences.len() <= 1 {
1501        // Single sentence or no boundaries - treat as atomic
1502        if should_insert_space_before_join(current_line) {
1503            current_line.push(' ');
1504        }
1505        current_line.push_str(marker);
1506        current_line.push_str(content);
1507        current_line.push_str(marker);
1508
1509        // Check if the emphasis content ends with sentence punctuation - if so, emit
1510        let trimmed = content.trim();
1511        let ends_with_punct = ends_with_sentence_punct(trimmed);
1512        if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1513            lines.push(current_line.clone());
1514            current_line.clear();
1515        }
1516    } else {
1517        // Multiple sentences - each gets its own emphasis markers
1518        for (i, sentence) in sentences.iter().enumerate() {
1519            let trimmed = sentence.trim();
1520            if trimmed.is_empty() {
1521                continue;
1522            }
1523
1524            if i == 0 {
1525                // First sentence: combine with current_line and emit
1526                if should_insert_space_before_join(current_line) {
1527                    current_line.push(' ');
1528                }
1529                current_line.push_str(marker);
1530                current_line.push_str(trimmed);
1531                current_line.push_str(marker);
1532
1533                // Check if this is a complete sentence
1534                let ends_with_punct = ends_with_sentence_punct(trimmed);
1535                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1536                    lines.push(current_line.clone());
1537                    current_line.clear();
1538                }
1539            } else if i == sentences.len() - 1 {
1540                // Last sentence: check if complete
1541                let ends_with_punct = ends_with_sentence_punct(trimmed);
1542
1543                let mut line = String::new();
1544                line.push_str(marker);
1545                line.push_str(trimmed);
1546                line.push_str(marker);
1547
1548                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1549                    lines.push(line);
1550                } else {
1551                    // Incomplete - keep in current_line for potential continuation
1552                    *current_line = line;
1553                }
1554            } else {
1555                // Middle sentences: emit with markers
1556                let mut line = String::new();
1557                line.push_str(marker);
1558                line.push_str(trimmed);
1559                line.push_str(marker);
1560                lines.push(line);
1561            }
1562        }
1563    }
1564}
1565
1566/// English break-words used for semantic line break splitting.
1567/// These are conjunctions and relative pronouns where a line break
1568/// reads naturally.
1569const BREAK_WORDS: &[&str] = &[
1570    "and",
1571    "or",
1572    "but",
1573    "nor",
1574    "yet",
1575    "so",
1576    "for",
1577    "which",
1578    "that",
1579    "because",
1580    "when",
1581    "if",
1582    "while",
1583    "where",
1584    "although",
1585    "though",
1586    "unless",
1587    "since",
1588    "after",
1589    "before",
1590    "until",
1591    "as",
1592    "once",
1593    "whether",
1594    "however",
1595    "therefore",
1596    "moreover",
1597    "furthermore",
1598    "nevertheless",
1599    "whereas",
1600];
1601
1602/// Check if a character is clause punctuation for semantic line breaks
1603fn is_clause_punctuation(c: char) -> bool {
1604    matches!(c, ',' | ';' | ':' | '\u{2014}') // comma, semicolon, colon, em dash
1605}
1606
1607/// Find the closing `)` that balances the `(` at the start of `slice`.
1608///
1609/// `offset` is the byte position of the `(` in the original full-line string;
1610/// it is used to translate local byte positions into global positions for
1611/// element-span lookups.  Parens inside markdown element spans are skipped so
1612/// that, e.g., the closing `)` of an inline link does not prematurely end the
1613/// scan.  The char's *start* byte (not byte-after) is used for the span check
1614/// so that closing element delimiters — which sit exactly at the span's
1615/// exclusive-end boundary — are correctly excluded.
1616///
1617/// Returns `(end_local, inner)` where `end_local` is the byte offset within
1618/// `slice` just past the closing `)`, and `inner` is the content between the
1619/// outermost `(` and `)`.
1620fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1621    debug_assert!(slice.starts_with('('));
1622    let mut depth: i32 = 0;
1623    for (local_byte, c) in slice.char_indices() {
1624        let global_byte = offset + local_byte;
1625        // When depth > 0, skip parens that belong to a markdown element.
1626        // Use the char's start byte so that a closing element delimiter
1627        // (whose byte_after equals the span's exclusive end) is treated as
1628        // inside the element rather than outside it.
1629        if depth > 0 && is_inside_element(global_byte, element_spans) {
1630            continue;
1631        }
1632        match c {
1633            '(' => depth += 1,
1634            ')' => {
1635                depth -= 1;
1636                if depth == 0 {
1637                    let end = local_byte + 1;
1638                    let inner = &slice[1..local_byte];
1639                    return Some((end, inner));
1640                }
1641            }
1642            _ => {}
1643        }
1644    }
1645    None
1646}
1647
1648/// Split a line at a parenthetical boundary for semantic line breaks.
1649///
1650/// Two strategies are tried in order:
1651///
1652/// 1. **Leading parenthetical** — if the line begins with `(`, isolate the
1653///    entire balanced group on this line and start the rest on the next.
1654///    This handles lines produced by a prior split that placed a `(` at the
1655///    very beginning.
1656///
1657/// 2. **Mid-line parenthetical** — find the rightmost balanced `(…)` whose
1658///    content spans multiple words and whose preceding text fits within
1659///    `[min_first_len, line_length]`.  Split just before the `(` so the
1660///    parenthetical begins the following line.
1661///
1662/// Parentheses that fall inside markdown element spans (links, code, etc.)
1663/// are ignored in both strategies.
1664fn split_at_parenthetical(
1665    text: &str,
1666    line_length: usize,
1667    element_spans: &[(usize, usize)],
1668    length_mode: ReflowLengthMode,
1669) -> Option<(String, String)> {
1670    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1671
1672    // Strategy 1: text starts with '(' — isolate the parenthetical as its own line.
1673    if text.starts_with('(')
1674        && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1675        && inner.contains(' ')
1676    {
1677        // If closing quotes or clause punctuation immediately follow the closing
1678        // ')', attach them to the parenthetical so the continuation line does
1679        // not start with a bare quote, comma, or semicolon.
1680        let tail = &text[end_local..];
1681        let attached_len = tail
1682            .char_indices()
1683            .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1684            .last()
1685            .map_or(0, |(idx, c)| idx + c.len_utf8());
1686        let first_end = end_local + attached_len;
1687        let rest_start = first_end;
1688        let first = &text[..first_end];
1689        let first_len = display_len(first, length_mode);
1690        // No MIN_SPLIT_RATIO check: a parenthetical unit is always a valid
1691        // semantic line regardless of its length.
1692        if first_len <= line_length {
1693            let rest = text[rest_start..].trim_start();
1694            if !rest.is_empty() {
1695                return Some((first.to_string(), rest.to_string()));
1696            }
1697        }
1698    }
1699
1700    // Strategy 2: find the rightmost multi-word '(' whose preceding text fits.
1701    let mut best_open_byte: Option<usize> = None;
1702    let mut pos = 0usize;
1703    while pos < text.len() {
1704        // '(' is ASCII so a single-byte comparison is safe in UTF-8.
1705        if text.as_bytes()[pos] != b'(' {
1706            let c = text[pos..].chars().next().unwrap();
1707            pos += c.len_utf8();
1708            continue;
1709        }
1710        // Skip '(' that are part of a markdown element (use start byte).
1711        if is_inside_element(pos, element_spans) {
1712            pos += 1;
1713            continue;
1714        }
1715        if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1716            let first = text[..pos].trim_end();
1717            let first_len = display_len(first, length_mode);
1718            if !first.is_empty()
1719                && first_len >= min_first_len
1720                && first_len <= line_length
1721                && inner.contains(' ')
1722                && best_open_byte.is_none_or(|prev| pos > prev)
1723            {
1724                best_open_byte = Some(pos);
1725            }
1726            pos += end_local;
1727        } else {
1728            pos += 1;
1729        }
1730    }
1731
1732    let open_byte = best_open_byte?;
1733    let first = text[..open_byte].trim_end().to_string();
1734    let rest = text[open_byte..].to_string();
1735    if first.is_empty() || rest.trim().is_empty() {
1736        return None;
1737    }
1738    Some((first, rest))
1739}
1740
1741/// Compute element spans for a flat text representation of elements.
1742/// Returns Vec of (start, end) byte offsets for non-Text elements,
1743/// so we can check that a split position doesn't fall inside them.
1744fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1745    let mut spans = Vec::new();
1746    let mut offset = 0;
1747    for element in elements {
1748        let rendered = format!("{element}");
1749        let len = rendered.len();
1750        if !matches!(element, Element::Text(_)) {
1751            spans.push((offset, offset + len));
1752        }
1753        offset += len;
1754    }
1755    spans
1756}
1757
1758/// Check if a byte position falls inside any non-Text element span
1759fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1760    spans.iter().any(|(start, end)| pos > *start && pos < *end)
1761}
1762
1763/// Minimum fraction of line_length that the first part of a split must occupy.
1764/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
1765const MIN_SPLIT_RATIO: f64 = 0.3;
1766
1767/// Split a line at the latest clause punctuation that keeps the first part
1768/// within `line_length`. Returns None if no valid split point exists or if
1769/// the split would create an unreasonably short first line.
1770fn split_at_clause_punctuation(
1771    text: &str,
1772    line_length: usize,
1773    element_spans: &[(usize, usize)],
1774    length_mode: ReflowLengthMode,
1775) -> Option<(String, String)> {
1776    let chars: Vec<char> = text.chars().collect();
1777    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1778
1779    // Find the char index where accumulated display width exceeds line_length
1780    let mut width_acc = 0;
1781    let mut search_end_char = 0;
1782    for (idx, &c) in chars.iter().enumerate() {
1783        let c_width = display_len(&c.to_string(), length_mode);
1784        if width_acc + c_width > line_length {
1785            break;
1786        }
1787        width_acc += c_width;
1788        search_end_char = idx + 1;
1789    }
1790
1791    // Scan backwards tracking parenthesis depth to skip clause punctuation
1792    // inside plain-text parenthetical groups.  Scanning right-to-left means
1793    // ')' opens a depth level and '(' closes it.  Parens that belong to a
1794    // markdown element are excluded using the char's start byte (not byte-after)
1795    // so that closing element delimiters at the span boundary are correctly
1796    // treated as part of the element.
1797    let mut paren_depth: i32 = 0;
1798    let mut best_pos = None;
1799    for i in (0..search_end_char).rev() {
1800        // Start byte of char i (for paren element check)
1801        let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1802        // Byte just after char i (for clause punctuation element check — existing convention)
1803        let byte_after: usize = byte_start + chars[i].len_utf8();
1804
1805        if !is_inside_element(byte_start, element_spans) {
1806            match chars[i] {
1807                ')' => paren_depth += 1,
1808                '(' => paren_depth = paren_depth.saturating_sub(1),
1809                _ => {}
1810            }
1811        }
1812
1813        if paren_depth == 0 && is_clause_punctuation(chars[i]) && !is_inside_element(byte_after, element_spans) {
1814            best_pos = Some(i);
1815            break;
1816        }
1817    }
1818
1819    let pos = best_pos?;
1820
1821    // Reject splits that create very short first lines
1822    let first: String = chars[..=pos].iter().collect();
1823    let first_display_len = display_len(&first, length_mode);
1824    if first_display_len < min_first_len {
1825        return None;
1826    }
1827
1828    // Split after the punctuation character
1829    let rest: String = chars[pos + 1..].iter().collect();
1830    let rest = rest.trim_start().to_string();
1831
1832    if rest.is_empty() {
1833        return None;
1834    }
1835
1836    Some((first, rest))
1837}
1838
1839/// Compute plain-text paren-depth at each byte offset in `text`.
1840///
1841/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
1842/// nesting depth at byte `i` — counting only `(` and `)` that fall
1843/// outside markdown element spans.  This lets callers quickly check
1844/// whether a byte position lies inside a plain-text parenthetical group.
1845fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1846    let mut map = vec![0i32; text.len()];
1847    let mut depth = 0i32;
1848    for (byte, c) in text.char_indices() {
1849        if !is_inside_element(byte, element_spans) {
1850            match c {
1851                '(' => depth += 1,
1852                ')' => depth = depth.saturating_sub(1),
1853                _ => {}
1854            }
1855        }
1856        // Fill the depth value for every byte of this (possibly multi-byte) char.
1857        let end = (byte + c.len_utf8()).min(map.len());
1858        for slot in &mut map[byte..end] {
1859            *slot = depth;
1860        }
1861    }
1862    map
1863}
1864
1865/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
1866/// group — i.e. it starts with `(`, ends with `)` (possibly followed by
1867/// clause punctuation), has balanced parens throughout, and the inner content
1868/// contains at least one space (matching the ≥2-word threshold used by
1869/// `split_at_parenthetical` when deciding to split).
1870///
1871/// Used to prevent the short-line merge step from collapsing intentional
1872/// parenthetical splits back into the previous line.
1873fn is_standalone_parenthetical(line: &str) -> bool {
1874    let trimmed = line.trim();
1875    if !trimmed.starts_with('(') {
1876        return false;
1877    }
1878    // Strip optional trailing clause punctuation to find the real end.
1879    let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1880    if !core.ends_with(')') {
1881        return false;
1882    }
1883    // Inner content must span multiple words (same threshold as split_at_parenthetical).
1884    let inner = &core[1..core.len() - 1];
1885    if !inner.contains(' ') {
1886        return false;
1887    }
1888    // Verify the parens are balanced (depth returns to 0 at the last ')').
1889    let mut depth = 0i32;
1890    for c in core.chars() {
1891        match c {
1892            '(' => depth += 1,
1893            ')' => depth -= 1,
1894            _ => {}
1895        }
1896        if depth < 0 {
1897            return false;
1898        }
1899    }
1900    depth == 0
1901}
1902
1903/// Split a line before the latest break-word that keeps the first part
1904/// within `line_length`. Returns None if no valid split point exists or if
1905/// the split would create an unreasonably short first line.
1906fn split_at_break_word(
1907    text: &str,
1908    line_length: usize,
1909    element_spans: &[(usize, usize)],
1910    length_mode: ReflowLengthMode,
1911) -> Option<(String, String)> {
1912    let lower = text.to_lowercase();
1913    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1914    let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
1915
1916    // Build a paren-depth map so we can skip break-words inside plain-text
1917    // parenthetical groups (matching the protection added to split_at_clause_punctuation).
1918    let depth_map = paren_depth_map(text, element_spans);
1919
1920    for &word in BREAK_WORDS {
1921        let mut search_start = 0;
1922        while let Some(pos) = lower[search_start..].find(word) {
1923            let abs_pos = search_start + pos;
1924
1925            // Verify it's a word boundary: preceded by space, followed by space
1926            let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1927            let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1928
1929            if preceded_by_space && followed_by_space {
1930                // The break goes BEFORE the word, so first part ends at abs_pos - 1
1931                let first_part = text[..abs_pos].trim_end();
1932                let first_part_len = display_len(first_part, length_mode);
1933
1934                // Skip break-words inside plain-text parenthetical groups.
1935                let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1936
1937                if first_part_len >= min_first_len
1938                    && first_part_len <= line_length
1939                    && !is_inside_element(abs_pos, element_spans)
1940                    && !inside_paren
1941                {
1942                    // Prefer the latest valid split point
1943                    if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1944                        best_split = Some((abs_pos, word.len()));
1945                    }
1946                }
1947            }
1948
1949            search_start = abs_pos + word.len();
1950        }
1951    }
1952
1953    let (byte_start, _word_len) = best_split?;
1954
1955    let first = text[..byte_start].trim_end().to_string();
1956    let rest = text[byte_start..].to_string();
1957
1958    if first.is_empty() || rest.trim().is_empty() {
1959        return None;
1960    }
1961
1962    Some((first, rest))
1963}
1964
1965/// Recursively cascade-split a line that exceeds line_length.
1966/// Tries clause punctuation first, then break-words, then word wrap.
1967fn cascade_split_line(
1968    text: &str,
1969    line_length: usize,
1970    abbreviations: &Option<Vec<String>>,
1971    length_mode: ReflowLengthMode,
1972    attr_lists: bool,
1973) -> Vec<String> {
1974    if line_length == 0 || display_len(text, length_mode) <= line_length {
1975        return vec![text.to_string()];
1976    }
1977
1978    let elements = parse_markdown_elements_inner(text, attr_lists);
1979    let element_spans = compute_element_spans(&elements);
1980
1981    // Try parenthetical boundary split (before clause punctuation so that
1982    // multi-word parentheticals are kept intact as semantic units)
1983    if let Some((first, rest)) = split_at_parenthetical(text, line_length, &element_spans, length_mode) {
1984        let mut result = vec![first];
1985        result.extend(cascade_split_line(
1986            &rest,
1987            line_length,
1988            abbreviations,
1989            length_mode,
1990            attr_lists,
1991        ));
1992        return result;
1993    }
1994
1995    // Try clause punctuation split
1996    if let Some((first, rest)) = split_at_clause_punctuation(text, line_length, &element_spans, length_mode) {
1997        let mut result = vec![first];
1998        result.extend(cascade_split_line(
1999            &rest,
2000            line_length,
2001            abbreviations,
2002            length_mode,
2003            attr_lists,
2004        ));
2005        return result;
2006    }
2007
2008    // Try break-word split
2009    if let Some((first, rest)) = split_at_break_word(text, line_length, &element_spans, length_mode) {
2010        let mut result = vec![first];
2011        result.extend(cascade_split_line(
2012            &rest,
2013            line_length,
2014            abbreviations,
2015            length_mode,
2016            attr_lists,
2017        ));
2018        return result;
2019    }
2020
2021    // Fallback: word wrap using existing reflow_elements
2022    let options = ReflowOptions {
2023        line_length,
2024        break_on_sentences: false,
2025        preserve_breaks: false,
2026        sentence_per_line: false,
2027        semantic_line_breaks: false,
2028        abbreviations: abbreviations.clone(),
2029        length_mode,
2030        attr_lists,
2031        require_sentence_capital: true,
2032        max_list_continuation_indent: None,
2033    };
2034    reflow_elements(&elements, &options)
2035}
2036
2037/// Reflow elements using semantic line breaks strategy:
2038/// 1. Split at sentence boundaries (always)
2039/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
2040fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2041    // Step 1: Split into sentences using existing sentence-per-line logic
2042    let sentence_lines =
2043        reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2044
2045    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
2046    // When line_length is 0 (unlimited), skip cascading — sentence splits only
2047    if options.line_length == 0 {
2048        return sentence_lines;
2049    }
2050
2051    let length_mode = options.length_mode;
2052    let mut result = Vec::new();
2053    for line in sentence_lines {
2054        if display_len(&line, length_mode) <= options.line_length {
2055            result.push(line);
2056        } else {
2057            result.extend(cascade_split_line(
2058                &line,
2059                options.line_length,
2060                &options.abbreviations,
2061                length_mode,
2062                options.attr_lists,
2063            ));
2064        }
2065    }
2066
2067    // Step 3: Merge very short trailing lines back into the previous line.
2068    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
2069    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2070    let mut merged: Vec<String> = Vec::with_capacity(result.len());
2071    for line in result {
2072        if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2073            // Don't merge a line that is itself a standalone parenthetical group —
2074            // it was placed on its own line intentionally by split_at_parenthetical.
2075            if is_standalone_parenthetical(&line) {
2076                merged.push(line);
2077                continue;
2078            }
2079
2080            // Don't merge across sentence boundaries — sentence splits are intentional
2081            let prev_ends_at_sentence = {
2082                let trimmed = merged.last().unwrap().trim_end();
2083                trimmed
2084                    .chars()
2085                    .rev()
2086                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2087                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2088            };
2089
2090            if !prev_ends_at_sentence {
2091                let prev = merged.last_mut().unwrap();
2092                let combined = format!("{prev} {line}");
2093                // Only merge if the combined line fits within the limit
2094                if display_len(&combined, length_mode) <= options.line_length {
2095                    *prev = combined;
2096                    continue;
2097                }
2098            }
2099        }
2100        merged.push(line);
2101    }
2102    merged
2103}
2104
2105/// Find the last space in `line` that is safe to split at.
2106/// Safe spaces are those NOT inside rendered non-Text elements.
2107/// `element_spans` contains (start, end) byte ranges of non-Text elements in the line.
2108/// Find the last space in `line` that is not inside any element span.
2109/// Spans use exclusive bounds (pos > start && pos < end) because element
2110/// delimiters (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never
2111/// spaces, so only interior positions need protection.
2112fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2113    line.char_indices()
2114        .rev()
2115        .map(|(pos, _)| pos)
2116        .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2117}
2118
2119/// Reflow elements into lines that fit within the line length
2120fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2121    let mut lines = Vec::new();
2122    let mut current_line = String::new();
2123    let mut current_length = 0;
2124    // Track byte spans of non-Text elements in current_line for safe splitting
2125    let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2126    let length_mode = options.length_mode;
2127
2128    for (idx, element) in elements.iter().enumerate() {
2129        // Derive the display width from the already-formatted string rather than
2130        // formatting the element a second time just to measure it.
2131        let element_str = format!("{element}");
2132        let element_len = display_len(&element_str, length_mode);
2133
2134        // Determine adjacency from the original elements, not from current_line.
2135        // Elements are adjacent when there's no whitespace between them in the source:
2136        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
2137        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
2138        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
2139        let is_adjacent_to_prev = if idx > 0 {
2140            match (&elements[idx - 1], element) {
2141                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2142                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2143                _ => true,
2144            }
2145        } else {
2146            false
2147        };
2148
2149        // For text elements that might need breaking
2150        if let Element::Text(text) = element {
2151            // Check if original text had leading whitespace
2152            let has_leading_space = text.starts_with(char::is_whitespace);
2153            // If this is a text element, always process it word by word
2154            let words: Vec<&str> = text.split_whitespace().collect();
2155
2156            for (i, word) in words.iter().enumerate() {
2157                let word_len = display_len(word, length_mode);
2158                // Check if this "word" is just punctuation that should stay attached
2159                let is_trailing_punct = word
2160                    .chars()
2161                    .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2162
2163                // First word of text adjacent to preceding non-text element
2164                // must stay attached (e.g., shortcode followed by punctuation or text)
2165                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2166
2167                if is_first_adjacent {
2168                    // Attach directly without space, preventing line break
2169                    if current_length + word_len > options.line_length && current_length > 0 {
2170                        // Would exceed — break before the adjacent group
2171                        // Use element-aware space search to avoid splitting inside links/code/etc.
2172                        if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2173                            let before = current_line[..last_space].trim_end().to_string();
2174                            let after = current_line[last_space + 1..].to_string();
2175                            lines.push(before);
2176                            current_line = format!("{after}{word}");
2177                            current_length = display_len(&current_line, length_mode);
2178                            current_line_element_spans.clear();
2179                        } else {
2180                            current_line.push_str(word);
2181                            current_length += word_len;
2182                        }
2183                    } else {
2184                        current_line.push_str(word);
2185                        current_length += word_len;
2186                    }
2187                } else if current_length > 0
2188                    && current_length + 1 + word_len > options.line_length
2189                    && !is_trailing_punct
2190                {
2191                    // Start a new line (but never for trailing punctuation)
2192                    lines.push(current_line.trim().to_string());
2193                    current_line = word.to_string();
2194                    current_length = word_len;
2195                    current_line_element_spans.clear();
2196                } else {
2197                    // Add word to current line
2198                    // Only add space if: we have content AND (this isn't the first word OR original had leading space)
2199                    // AND this isn't trailing punctuation (which attaches directly)
2200                    if current_length > 0 && (i > 0 || has_leading_space) && !is_trailing_punct {
2201                        current_line.push(' ');
2202                        current_length += 1;
2203                    }
2204                    current_line.push_str(word);
2205                    current_length += word_len;
2206                }
2207            }
2208        } else if matches!(
2209            element,
2210            Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough(_)
2211        ) && element_len > options.line_length
2212        {
2213            // Italic, bold, and strikethrough with content longer than line_length need word wrapping.
2214            // Split content word-by-word, attach the opening marker to the first word
2215            // and the closing marker to the last word.
2216            let (content, marker): (&str, &str) = match element {
2217                Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2218                Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2219                Element::Strikethrough(content) => (content.as_str(), "~~"),
2220                _ => unreachable!(),
2221            };
2222
2223            let words: Vec<&str> = content.split_whitespace().collect();
2224            let n = words.len();
2225
2226            if n == 0 {
2227                // Empty span — treat as atomic
2228                let full = format!("{marker}{marker}");
2229                let full_len = display_len(&full, length_mode);
2230                if !is_adjacent_to_prev && current_length > 0 {
2231                    current_line.push(' ');
2232                    current_length += 1;
2233                }
2234                current_line.push_str(&full);
2235                current_length += full_len;
2236            } else {
2237                for (i, word) in words.iter().enumerate() {
2238                    let is_first = i == 0;
2239                    let is_last = i == n - 1;
2240                    let word_str: String = match (is_first, is_last) {
2241                        (true, true) => format!("{marker}{word}{marker}"),
2242                        (true, false) => format!("{marker}{word}"),
2243                        (false, true) => format!("{word}{marker}"),
2244                        (false, false) => word.to_string(),
2245                    };
2246                    let word_len = display_len(&word_str, length_mode);
2247
2248                    let needs_space = if is_first {
2249                        !is_adjacent_to_prev && current_length > 0
2250                    } else {
2251                        current_length > 0
2252                    };
2253
2254                    if needs_space && current_length + 1 + word_len > options.line_length {
2255                        lines.push(current_line.trim_end().to_string());
2256                        current_line = word_str;
2257                        current_length = word_len;
2258                        current_line_element_spans.clear();
2259                    } else {
2260                        if needs_space {
2261                            current_line.push(' ');
2262                            current_length += 1;
2263                        }
2264                        current_line.push_str(&word_str);
2265                        current_length += word_len;
2266                    }
2267                }
2268            }
2269        } else {
2270            // For non-text elements (code, links, references), treat as atomic units
2271            // These should never be broken across lines
2272
2273            if is_adjacent_to_prev {
2274                // Adjacent to preceding text — attach directly without space
2275                if current_length + element_len > options.line_length {
2276                    // Would exceed limit — break before the adjacent word group
2277                    // Use element-aware space search to avoid splitting inside links/code/etc.
2278                    if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2279                        let before = current_line[..last_space].trim_end().to_string();
2280                        let after = current_line[last_space + 1..].to_string();
2281                        lines.push(before);
2282                        current_line = format!("{after}{element_str}");
2283                        current_length = display_len(&current_line, length_mode);
2284                        current_line_element_spans.clear();
2285                        // Record the element span in the new current_line
2286                        let start = after.len();
2287                        current_line_element_spans.push((start, start + element_str.len()));
2288                    } else {
2289                        // No safe space to break at — accept the long line
2290                        let start = current_line.len();
2291                        current_line.push_str(&element_str);
2292                        current_length += element_len;
2293                        current_line_element_spans.push((start, current_line.len()));
2294                    }
2295                } else {
2296                    let start = current_line.len();
2297                    current_line.push_str(&element_str);
2298                    current_length += element_len;
2299                    current_line_element_spans.push((start, current_line.len()));
2300                }
2301            } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2302                // Not adjacent, would exceed — start new line
2303                lines.push(current_line.trim().to_string());
2304                current_line.clone_from(&element_str);
2305                current_length = element_len;
2306                current_line_element_spans.clear();
2307                current_line_element_spans.push((0, element_str.len()));
2308            } else {
2309                // Not adjacent, fits — add with space
2310                let ends_with_opener =
2311                    current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2312                if current_length > 0 && !ends_with_opener {
2313                    current_line.push(' ');
2314                    current_length += 1;
2315                }
2316                let start = current_line.len();
2317                current_line.push_str(&element_str);
2318                current_length += element_len;
2319                current_line_element_spans.push((start, current_line.len()));
2320            }
2321        }
2322    }
2323
2324    // Don't forget the last line
2325    if !current_line.is_empty() {
2326        lines.push(current_line.trim_end().to_string());
2327    }
2328
2329    lines
2330}
2331
2332/// Reflow markdown content preserving structure
2333pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2334    let lines: Vec<&str> = content.lines().collect();
2335    let mut result = Vec::new();
2336    let mut i = 0;
2337
2338    while i < lines.len() {
2339        let line = lines[i];
2340        let trimmed = line.trim();
2341
2342        // Preserve empty lines
2343        if trimmed.is_empty() {
2344            result.push(String::new());
2345            i += 1;
2346            continue;
2347        }
2348
2349        // Preserve headings as-is
2350        if trimmed.starts_with('#') {
2351            result.push(line.to_string());
2352            i += 1;
2353            continue;
2354        }
2355
2356        // Preserve Quarto/Pandoc div markers (:::) as-is
2357        if trimmed.starts_with(":::") {
2358            result.push(line.to_string());
2359            i += 1;
2360            continue;
2361        }
2362
2363        // Preserve fenced code blocks
2364        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2365            result.push(line.to_string());
2366            i += 1;
2367            // Copy lines until closing fence
2368            while i < lines.len() {
2369                result.push(lines[i].to_string());
2370                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2371                    i += 1;
2372                    break;
2373                }
2374                i += 1;
2375            }
2376            continue;
2377        }
2378
2379        // Preserve indented code blocks (4+ columns accounting for tab expansion)
2380        if calculate_indentation_width_default(line) >= 4 {
2381            // Collect all consecutive indented lines
2382            result.push(line.to_string());
2383            i += 1;
2384            while i < lines.len() {
2385                let next_line = lines[i];
2386                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
2387                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2388                    result.push(next_line.to_string());
2389                    i += 1;
2390                } else {
2391                    break;
2392                }
2393            }
2394            continue;
2395        }
2396
2397        // Preserve block quotes (but reflow their content)
2398        if trimmed.starts_with('>') {
2399            // find() returns byte position which is correct for str slicing
2400            // The unwrap is safe because we already verified trimmed starts with '>'
2401            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2402            let quote_prefix = line[0..=gt_pos].to_string();
2403            let quote_content = &line[quote_prefix.len()..].trim_start();
2404
2405            let reflowed = reflow_line(quote_content, options);
2406            for reflowed_line in &reflowed {
2407                result.push(format!("{quote_prefix} {reflowed_line}"));
2408            }
2409            i += 1;
2410            continue;
2411        }
2412
2413        // Preserve horizontal rules first (before checking for lists)
2414        if is_horizontal_rule(trimmed) {
2415            result.push(line.to_string());
2416            i += 1;
2417            continue;
2418        }
2419
2420        // Preserve lists (but not horizontal rules)
2421        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2422            // Find the list marker and preserve indentation
2423            let indent = line.len() - line.trim_start().len();
2424            let indent_str = " ".repeat(indent);
2425
2426            // For numbered lists, find the period and the space after it
2427            // For bullet lists, find the marker and the space after it
2428            let mut marker_end = indent;
2429            let mut content_start = indent;
2430
2431            if trimmed.chars().next().is_some_and(char::is_numeric) {
2432                // Numbered list: find the period
2433                if let Some(period_pos) = line[indent..].find('.') {
2434                    marker_end = indent + period_pos + 1; // Include the period
2435                    content_start = marker_end;
2436                    // Skip any spaces after the period to find content start
2437                    // Use byte-based check since content_start is a byte index
2438                    // This is safe because space is ASCII (single byte)
2439                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2440                        content_start += 1;
2441                    }
2442                }
2443            } else {
2444                // Bullet list: marker is single character
2445                marker_end = indent + 1; // Just the marker character
2446                content_start = marker_end;
2447                // Skip any spaces after the marker
2448                // Use byte-based check since content_start is a byte index
2449                // This is safe because space is ASCII (single byte)
2450                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2451                    content_start += 1;
2452                }
2453            }
2454
2455            // Minimum indent for continuation lines (based on list marker, before checkbox)
2456            let min_continuation_indent = content_start;
2457
2458            // Detect checkbox/task list markers: [ ], [x], [X]
2459            // GFM task lists work with both unordered and ordered lists
2460            let rest = &line[content_start..];
2461            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2462                marker_end = content_start + 3; // Include the checkbox `[ ]`
2463                content_start += 4; // Skip past `[ ] `
2464            }
2465
2466            let marker = &line[indent..marker_end];
2467
2468            // Collect all content for this list item (including continuation lines)
2469            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
2470            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2471            i += 1;
2472
2473            // Collect continuation lines (indented lines that are part of this list item)
2474            // Use the base marker indent (not checkbox-extended) for collection,
2475            // since users may indent continuations to the bullet level, not the checkbox level
2476            while i < lines.len() {
2477                let next_line = lines[i];
2478                let next_trimmed = next_line.trim();
2479
2480                // Stop if we hit an empty line or another list item or special block
2481                if is_block_boundary(next_trimmed) {
2482                    break;
2483                }
2484
2485                // Check if this line is indented (continuation of list item)
2486                let next_indent = next_line.len() - next_line.trim_start().len();
2487                if next_indent >= min_continuation_indent {
2488                    // This is a continuation line - add its content
2489                    // Preserve hard breaks while trimming excessive whitespace
2490                    let trimmed_start = next_line.trim_start();
2491                    list_content.push(trim_preserving_hard_break(trimmed_start));
2492                    i += 1;
2493                } else {
2494                    // Not indented enough, not part of this list item
2495                    break;
2496                }
2497            }
2498
2499            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
2500            // Hard breaks should prevent joining with the next line
2501            let combined_content = if options.preserve_breaks {
2502                list_content[0].clone()
2503            } else {
2504                // Check if any lines have hard breaks - if so, preserve the structure
2505                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2506                if has_hard_breaks {
2507                    // Don't join lines with hard breaks - keep them separate with newlines
2508                    list_content.join("\n")
2509                } else {
2510                    // No hard breaks, safe to join with spaces
2511                    list_content.join(" ")
2512                }
2513            };
2514
2515            // Calculate the proper indentation for continuation lines
2516            let trimmed_marker = marker;
2517            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2518                // Cap the relative indent (past the nesting level) to max_indent,
2519                // then add back the nesting indent so nested items stay correct
2520                indent + (content_start - indent).min(max_indent)
2521            } else {
2522                content_start
2523            };
2524
2525            // Adjust line length to account for list marker and space
2526            let prefix_length = indent + trimmed_marker.len() + 1;
2527
2528            // Create adjusted options with reduced line length
2529            let adjusted_options = ReflowOptions {
2530                line_length: options.line_length.saturating_sub(prefix_length),
2531                ..options.clone()
2532            };
2533
2534            let reflowed = reflow_line(&combined_content, &adjusted_options);
2535            for (j, reflowed_line) in reflowed.iter().enumerate() {
2536                if j == 0 {
2537                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2538                } else {
2539                    // Continuation lines aligned with text after marker
2540                    let continuation_indent = " ".repeat(continuation_spaces);
2541                    result.push(format!("{continuation_indent}{reflowed_line}"));
2542                }
2543            }
2544            continue;
2545        }
2546
2547        // Preserve tables
2548        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2549            result.push(line.to_string());
2550            i += 1;
2551            continue;
2552        }
2553
2554        // Preserve reference definitions
2555        if trimmed.starts_with('[') && line.contains("]:") {
2556            result.push(line.to_string());
2557            i += 1;
2558            continue;
2559        }
2560
2561        // Preserve definition list items (extended markdown)
2562        if is_definition_list_item(trimmed) {
2563            result.push(line.to_string());
2564            i += 1;
2565            continue;
2566        }
2567
2568        // Check if this is a single line that doesn't need processing
2569        let mut is_single_line_paragraph = true;
2570        if i + 1 < lines.len() {
2571            let next_trimmed = lines[i + 1].trim();
2572            // Check if next line continues this paragraph
2573            if !is_block_boundary(next_trimmed) {
2574                is_single_line_paragraph = false;
2575            }
2576        }
2577
2578        // If it's a single line that fits, just add it as-is
2579        if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2580            result.push(line.to_string());
2581            i += 1;
2582            continue;
2583        }
2584
2585        // For regular paragraphs, collect consecutive lines
2586        let mut paragraph_parts = Vec::new();
2587        let mut current_part = vec![line];
2588        i += 1;
2589
2590        // If preserve_breaks is true, treat each line separately
2591        if options.preserve_breaks {
2592            // Don't collect consecutive lines - just reflow this single line
2593            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2594                Some("\\")
2595            } else if line.ends_with("  ") {
2596                Some("  ")
2597            } else {
2598                None
2599            };
2600            let reflowed = reflow_line(line, options);
2601
2602            // Preserve hard breaks (two trailing spaces or backslash)
2603            if let Some(break_marker) = hard_break_type {
2604                if !reflowed.is_empty() {
2605                    let mut reflowed_with_break = reflowed;
2606                    let last_idx = reflowed_with_break.len() - 1;
2607                    if !has_hard_break(&reflowed_with_break[last_idx]) {
2608                        reflowed_with_break[last_idx].push_str(break_marker);
2609                    }
2610                    result.extend(reflowed_with_break);
2611                }
2612            } else {
2613                result.extend(reflowed);
2614            }
2615        } else {
2616            // Original behavior: collect consecutive lines into a paragraph
2617            while i < lines.len() {
2618                let prev_line = if !current_part.is_empty() {
2619                    current_part.last().unwrap()
2620                } else {
2621                    ""
2622                };
2623                let next_line = lines[i];
2624                let next_trimmed = next_line.trim();
2625
2626                // Stop at empty lines or special blocks
2627                if is_block_boundary(next_trimmed) {
2628                    break;
2629                }
2630
2631                // Check if previous line ends with hard break (two spaces or backslash)
2632                // or is a complete sentence in sentence_per_line mode
2633                let prev_trimmed = prev_line.trim();
2634                let abbreviations = get_abbreviations(&options.abbreviations);
2635                let ends_with_sentence = (prev_trimmed.ends_with('.')
2636                    || prev_trimmed.ends_with('!')
2637                    || prev_trimmed.ends_with('?')
2638                    || prev_trimmed.ends_with(".*")
2639                    || prev_trimmed.ends_with("!*")
2640                    || prev_trimmed.ends_with("?*")
2641                    || prev_trimmed.ends_with("._")
2642                    || prev_trimmed.ends_with("!_")
2643                    || prev_trimmed.ends_with("?_")
2644                    // Quote-terminated sentences (straight and curly quotes)
2645                    || prev_trimmed.ends_with(".\"")
2646                    || prev_trimmed.ends_with("!\"")
2647                    || prev_trimmed.ends_with("?\"")
2648                    || prev_trimmed.ends_with(".'")
2649                    || prev_trimmed.ends_with("!'")
2650                    || prev_trimmed.ends_with("?'")
2651                    || prev_trimmed.ends_with(".\u{201D}")
2652                    || prev_trimmed.ends_with("!\u{201D}")
2653                    || prev_trimmed.ends_with("?\u{201D}")
2654                    || prev_trimmed.ends_with(".\u{2019}")
2655                    || prev_trimmed.ends_with("!\u{2019}")
2656                    || prev_trimmed.ends_with("?\u{2019}"))
2657                    && !text_ends_with_abbreviation(
2658                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2659                        &abbreviations,
2660                    );
2661
2662                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2663                    // Start a new part after hard break or complete sentence
2664                    paragraph_parts.push(current_part.join(" "));
2665                    current_part = vec![next_line];
2666                } else {
2667                    current_part.push(next_line);
2668                }
2669                i += 1;
2670            }
2671
2672            // Add the last part
2673            if !current_part.is_empty() {
2674                if current_part.len() == 1 {
2675                    // Single line, don't add trailing space
2676                    paragraph_parts.push(current_part[0].to_string());
2677                } else {
2678                    paragraph_parts.push(current_part.join(" "));
2679                }
2680            }
2681
2682            // Reflow each part separately, preserving hard breaks
2683            for (j, part) in paragraph_parts.iter().enumerate() {
2684                let reflowed = reflow_line(part, options);
2685                result.extend(reflowed);
2686
2687                // Preserve hard break by ensuring last line of part ends with hard break marker
2688                // Use two spaces as the default hard break format for reflows
2689                // But don't add hard breaks in sentence_per_line mode - lines are already separate
2690                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2691                    let last_idx = result.len() - 1;
2692                    if !has_hard_break(&result[last_idx]) {
2693                        result[last_idx].push_str("  ");
2694                    }
2695                }
2696            }
2697        }
2698    }
2699
2700    // Preserve trailing newline if the original content had one
2701    let result_text = result.join("\n");
2702    if content.ends_with('\n') && !result_text.ends_with('\n') {
2703        format!("{result_text}\n")
2704    } else {
2705        result_text
2706    }
2707}
2708
2709/// Information about a reflowed paragraph
2710#[derive(Debug, Clone)]
2711pub struct ParagraphReflow {
2712    /// Starting byte offset of the paragraph in the original content
2713    pub start_byte: usize,
2714    /// Ending byte offset of the paragraph in the original content
2715    pub end_byte: usize,
2716    /// The reflowed text for this paragraph
2717    pub reflowed_text: String,
2718}
2719
2720/// A collected blockquote line used for style-preserving reflow.
2721///
2722/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
2723/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
2724/// rather than constructing the struct directly.
2725#[derive(Debug, Clone)]
2726pub struct BlockquoteLineData {
2727    /// Trimmed content without the `> ` prefix.
2728    pub(crate) content: String,
2729    /// Whether this line carries an explicit blockquote marker.
2730    pub(crate) is_explicit: bool,
2731    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
2732    pub(crate) prefix: Option<String>,
2733}
2734
2735impl BlockquoteLineData {
2736    /// Create an explicit (marker-bearing) blockquote line.
2737    pub fn explicit(content: String, prefix: String) -> Self {
2738        Self {
2739            content,
2740            is_explicit: true,
2741            prefix: Some(prefix),
2742        }
2743    }
2744
2745    /// Create a lazy continuation line (no blockquote marker).
2746    pub fn lazy(content: String) -> Self {
2747        Self {
2748            content,
2749            is_explicit: false,
2750            prefix: None,
2751        }
2752    }
2753}
2754
2755/// Style for blockquote continuation lines after reflow.
2756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2757pub enum BlockquoteContinuationStyle {
2758    Explicit,
2759    Lazy,
2760}
2761
2762/// Determine the continuation style for a blockquote paragraph from its collected lines.
2763///
2764/// The first line is always explicit (it carries the marker), so only continuation
2765/// lines (index 1+) are counted. Ties resolve to `Explicit`.
2766///
2767/// When the slice has only one element (no continuation lines to inspect), both
2768/// counts are zero and the tie-breaking rule returns `Explicit`.
2769pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2770    let mut explicit_count = 0usize;
2771    let mut lazy_count = 0usize;
2772
2773    for line in lines.iter().skip(1) {
2774        if line.is_explicit {
2775            explicit_count += 1;
2776        } else {
2777            lazy_count += 1;
2778        }
2779    }
2780
2781    if explicit_count > 0 && lazy_count == 0 {
2782        BlockquoteContinuationStyle::Explicit
2783    } else if lazy_count > 0 && explicit_count == 0 {
2784        BlockquoteContinuationStyle::Lazy
2785    } else if explicit_count >= lazy_count {
2786        BlockquoteContinuationStyle::Explicit
2787    } else {
2788        BlockquoteContinuationStyle::Lazy
2789    }
2790}
2791
2792/// Determine the dominant blockquote prefix for a paragraph.
2793///
2794/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
2795/// first appearance. Falls back to `fallback` when no explicit lines are present.
2796pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2797    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2798
2799    for (idx, line) in lines.iter().enumerate() {
2800        let Some(prefix) = line.prefix.as_ref() else {
2801            continue;
2802        };
2803        counts
2804            .entry(prefix.clone())
2805            .and_modify(|entry| entry.0 += 1)
2806            .or_insert((1, idx));
2807    }
2808
2809    counts
2810        .into_iter()
2811        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2812            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2813        })
2814        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2815}
2816
2817/// Whether a reflowed blockquote content line must carry an explicit prefix.
2818///
2819/// Lines that would start a new block structure (headings, fences, lists, etc.)
2820/// cannot safely use lazy continuation syntax.
2821pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2822    let trimmed = content_line.trim_start();
2823    trimmed.starts_with('>')
2824        || trimmed.starts_with('#')
2825        || trimmed.starts_with("```")
2826        || trimmed.starts_with("~~~")
2827        || is_unordered_list_marker(trimmed)
2828        || is_numbered_list_item(trimmed)
2829        || is_horizontal_rule(trimmed)
2830        || is_definition_list_item(trimmed)
2831        || (trimmed.starts_with('[') && trimmed.contains("]:"))
2832        || trimmed.starts_with(":::")
2833        || (trimmed.starts_with('<')
2834            && !trimmed.starts_with("<http")
2835            && !trimmed.starts_with("<https")
2836            && !trimmed.starts_with("<mailto:"))
2837}
2838
2839/// Reflow blockquote content lines and apply continuation style.
2840///
2841/// Segments separated by hard breaks are reflowed independently. The output lines
2842/// receive blockquote prefixes according to `continuation_style`: the first line and
2843/// any line that would start a new block structure always get an explicit prefix;
2844/// other lines follow the detected style.
2845///
2846/// Returns the styled, reflowed lines (without a trailing newline).
2847pub fn reflow_blockquote_content(
2848    lines: &[BlockquoteLineData],
2849    explicit_prefix: &str,
2850    continuation_style: BlockquoteContinuationStyle,
2851    options: &ReflowOptions,
2852) -> Vec<String> {
2853    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2854    let segments = split_into_segments_strs(&content_strs);
2855    let mut reflowed_content_lines: Vec<String> = Vec::new();
2856
2857    for segment in segments {
2858        let hard_break_type = segment.last().and_then(|&line| {
2859            let line = line.strip_suffix('\r').unwrap_or(line);
2860            if line.ends_with('\\') {
2861                Some("\\")
2862            } else if line.ends_with("  ") {
2863                Some("  ")
2864            } else {
2865                None
2866            }
2867        });
2868
2869        let pieces: Vec<&str> = segment
2870            .iter()
2871            .map(|&line| {
2872                if let Some(l) = line.strip_suffix('\\') {
2873                    l.trim_end()
2874                } else if let Some(l) = line.strip_suffix("  ") {
2875                    l.trim_end()
2876                } else {
2877                    line.trim_end()
2878                }
2879            })
2880            .collect();
2881
2882        let segment_text = pieces.join(" ");
2883        let segment_text = segment_text.trim();
2884        if segment_text.is_empty() {
2885            continue;
2886        }
2887
2888        let mut reflowed = reflow_line(segment_text, options);
2889        if let Some(break_marker) = hard_break_type
2890            && !reflowed.is_empty()
2891        {
2892            let last_idx = reflowed.len() - 1;
2893            if !has_hard_break(&reflowed[last_idx]) {
2894                reflowed[last_idx].push_str(break_marker);
2895            }
2896        }
2897        reflowed_content_lines.extend(reflowed);
2898    }
2899
2900    let mut styled_lines: Vec<String> = Vec::new();
2901    for (idx, line) in reflowed_content_lines.iter().enumerate() {
2902        let force_explicit = idx == 0
2903            || continuation_style == BlockquoteContinuationStyle::Explicit
2904            || should_force_explicit_blockquote_line(line);
2905        if force_explicit {
2906            styled_lines.push(format!("{explicit_prefix}{line}"));
2907        } else {
2908            styled_lines.push(line.clone());
2909        }
2910    }
2911
2912    styled_lines
2913}
2914
2915fn is_blockquote_content_boundary(content: &str) -> bool {
2916    let trimmed = content.trim();
2917    trimmed.is_empty()
2918        || is_block_boundary(trimmed)
2919        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2920        || trimmed.starts_with(":::")
2921        || crate::utils::is_template_directive_only(content)
2922        || is_standalone_attr_list(content)
2923        || is_snippet_block_delimiter(content)
2924}
2925
2926fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2927    let mut segments = Vec::new();
2928    let mut current = Vec::new();
2929
2930    for &line in lines {
2931        current.push(line);
2932        if has_hard_break(line) {
2933            segments.push(current);
2934            current = Vec::new();
2935        }
2936    }
2937
2938    if !current.is_empty() {
2939        segments.push(current);
2940    }
2941
2942    segments
2943}
2944
2945fn reflow_blockquote_paragraph_at_line(
2946    content: &str,
2947    lines: &[&str],
2948    target_idx: usize,
2949    options: &ReflowOptions,
2950) -> Option<ParagraphReflow> {
2951    let mut anchor_idx = target_idx;
2952    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
2953        parsed.nesting_level
2954    } else {
2955        let mut found = None;
2956        let mut idx = target_idx;
2957        loop {
2958            if lines[idx].trim().is_empty() {
2959                break;
2960            }
2961            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
2962                found = Some((idx, parsed.nesting_level));
2963                break;
2964            }
2965            if idx == 0 {
2966                break;
2967            }
2968            idx -= 1;
2969        }
2970        let (idx, level) = found?;
2971        anchor_idx = idx;
2972        level
2973    };
2974
2975    // Expand backward to capture prior quote content at the same nesting level.
2976    let mut para_start = anchor_idx;
2977    while para_start > 0 {
2978        let prev_idx = para_start - 1;
2979        let prev_line = lines[prev_idx];
2980
2981        if prev_line.trim().is_empty() {
2982            break;
2983        }
2984
2985        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
2986            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
2987                break;
2988            }
2989            para_start = prev_idx;
2990            continue;
2991        }
2992
2993        let prev_lazy = prev_line.trim_start();
2994        if is_blockquote_content_boundary(prev_lazy) {
2995            break;
2996        }
2997        para_start = prev_idx;
2998    }
2999
3000    // Lazy continuation cannot precede the first explicit marker.
3001    while para_start < lines.len() {
3002        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3003            para_start += 1;
3004            continue;
3005        };
3006        target_level = parsed.nesting_level;
3007        break;
3008    }
3009
3010    if para_start >= lines.len() || para_start > target_idx {
3011        return None;
3012    }
3013
3014    // Collect explicit lines at target level and lazy continuation lines.
3015    // Each entry is (original_line_idx, BlockquoteLineData).
3016    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3017    let mut idx = para_start;
3018    while idx < lines.len() {
3019        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3020            break;
3021        }
3022
3023        let line = lines[idx];
3024        if line.trim().is_empty() {
3025            break;
3026        }
3027
3028        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3029            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3030                break;
3031            }
3032            collected.push((
3033                idx,
3034                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3035            ));
3036            idx += 1;
3037            continue;
3038        }
3039
3040        let lazy_content = line.trim_start();
3041        if is_blockquote_content_boundary(lazy_content) {
3042            break;
3043        }
3044
3045        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3046        idx += 1;
3047    }
3048
3049    if collected.is_empty() {
3050        return None;
3051    }
3052
3053    let para_end = collected[collected.len() - 1].0;
3054    if target_idx < para_start || target_idx > para_end {
3055        return None;
3056    }
3057
3058    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3059
3060    let fallback_prefix = line_data
3061        .iter()
3062        .find_map(|d| d.prefix.clone())
3063        .unwrap_or_else(|| "> ".to_string());
3064    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3065    let continuation_style = blockquote_continuation_style(&line_data);
3066
3067    let adjusted_line_length = options
3068        .line_length
3069        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3070        .max(1);
3071
3072    let adjusted_options = ReflowOptions {
3073        line_length: adjusted_line_length,
3074        ..options.clone()
3075    };
3076
3077    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3078
3079    if styled_lines.is_empty() {
3080        return None;
3081    }
3082
3083    // Calculate byte offsets.
3084    let mut start_byte = 0;
3085    for line in lines.iter().take(para_start) {
3086        start_byte += line.len() + 1;
3087    }
3088
3089    let mut end_byte = start_byte;
3090    for line in lines.iter().take(para_end + 1).skip(para_start) {
3091        end_byte += line.len() + 1;
3092    }
3093
3094    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3095    if !includes_trailing_newline {
3096        end_byte -= 1;
3097    }
3098
3099    let reflowed_joined = styled_lines.join("\n");
3100    let reflowed_text = if includes_trailing_newline {
3101        if reflowed_joined.ends_with('\n') {
3102            reflowed_joined
3103        } else {
3104            format!("{reflowed_joined}\n")
3105        }
3106    } else if reflowed_joined.ends_with('\n') {
3107        reflowed_joined.trim_end_matches('\n').to_string()
3108    } else {
3109        reflowed_joined
3110    };
3111
3112    Some(ParagraphReflow {
3113        start_byte,
3114        end_byte,
3115        reflowed_text,
3116    })
3117}
3118
3119/// Reflow a single paragraph at the specified line number
3120///
3121/// This function finds the paragraph containing the given line number,
3122/// reflows it according to the specified line length, and returns
3123/// information about the paragraph location and its reflowed text.
3124///
3125/// # Arguments
3126///
3127/// * `content` - The full document content
3128/// * `line_number` - The 1-based line number within the paragraph to reflow
3129/// * `line_length` - The target line length for reflowing
3130///
3131/// # Returns
3132///
3133/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
3134/// or `None` if the line number is out of bounds or the content at that
3135/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
3136pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3137    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3138}
3139
3140/// Reflow a paragraph at the given line with a specific length mode.
3141pub fn reflow_paragraph_at_line_with_mode(
3142    content: &str,
3143    line_number: usize,
3144    line_length: usize,
3145    length_mode: ReflowLengthMode,
3146) -> Option<ParagraphReflow> {
3147    let options = ReflowOptions {
3148        line_length,
3149        length_mode,
3150        ..Default::default()
3151    };
3152    reflow_paragraph_at_line_with_options(content, line_number, &options)
3153}
3154
3155/// Reflow a paragraph at the given line using the provided options.
3156///
3157/// This is the canonical implementation used by both the rule's fix mode and the
3158/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
3159/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
3160///
3161/// # Returns
3162///
3163/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
3164/// if the line is out of bounds or sits inside a non-reflow-able construct.
3165pub fn reflow_paragraph_at_line_with_options(
3166    content: &str,
3167    line_number: usize,
3168    options: &ReflowOptions,
3169) -> Option<ParagraphReflow> {
3170    if line_number == 0 {
3171        return None;
3172    }
3173
3174    let lines: Vec<&str> = content.lines().collect();
3175
3176    // Check if line number is valid (1-based)
3177    if line_number > lines.len() {
3178        return None;
3179    }
3180
3181    let target_idx = line_number - 1; // Convert to 0-based
3182    let target_line = lines[target_idx];
3183    let trimmed = target_line.trim();
3184
3185    // Handle blockquote paragraphs (including lazy continuation lines) with
3186    // style-preserving output.
3187    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3188        return Some(blockquote_reflow);
3189    }
3190
3191    // Don't reflow special blocks
3192    if is_paragraph_boundary(trimmed, target_line) {
3193        return None;
3194    }
3195
3196    // Find paragraph start - scan backward until blank line or special block
3197    let mut para_start = target_idx;
3198    while para_start > 0 {
3199        let prev_idx = para_start - 1;
3200        let prev_line = lines[prev_idx];
3201        let prev_trimmed = prev_line.trim();
3202
3203        // Stop at blank line or special blocks
3204        if is_paragraph_boundary(prev_trimmed, prev_line) {
3205            break;
3206        }
3207
3208        para_start = prev_idx;
3209    }
3210
3211    // Find paragraph end - scan forward until blank line or special block
3212    let mut para_end = target_idx;
3213    while para_end + 1 < lines.len() {
3214        let next_idx = para_end + 1;
3215        let next_line = lines[next_idx];
3216        let next_trimmed = next_line.trim();
3217
3218        // Stop at blank line or special blocks
3219        if is_paragraph_boundary(next_trimmed, next_line) {
3220            break;
3221        }
3222
3223        para_end = next_idx;
3224    }
3225
3226    // Extract paragraph lines
3227    let paragraph_lines = &lines[para_start..=para_end];
3228
3229    // Calculate byte offsets
3230    let mut start_byte = 0;
3231    for line in lines.iter().take(para_start) {
3232        start_byte += line.len() + 1; // +1 for newline
3233    }
3234
3235    let mut end_byte = start_byte;
3236    for line in paragraph_lines {
3237        end_byte += line.len() + 1; // +1 for newline
3238    }
3239
3240    // Track whether the byte range includes a trailing newline
3241    // (it doesn't if this is the last line and the file doesn't end with newline)
3242    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3243
3244    // Adjust end_byte if the last line doesn't have a newline
3245    if !includes_trailing_newline {
3246        end_byte -= 1;
3247    }
3248
3249    // Join paragraph lines and reflow
3250    let paragraph_text = paragraph_lines.join("\n");
3251
3252    // Reflow the paragraph using reflow_markdown to handle it properly
3253    let reflowed = reflow_markdown(&paragraph_text, options);
3254
3255    // Ensure reflowed text matches whether the byte range includes a trailing newline
3256    // This is critical: if the range includes a newline, the replacement must too,
3257    // otherwise the next line will get appended to the reflowed paragraph
3258    let reflowed_text = if includes_trailing_newline {
3259        // Range includes newline - ensure reflowed text has one
3260        if reflowed.ends_with('\n') {
3261            reflowed
3262        } else {
3263            format!("{reflowed}\n")
3264        }
3265    } else {
3266        // Range doesn't include newline - ensure reflowed text doesn't have one
3267        if reflowed.ends_with('\n') {
3268            reflowed.trim_end_matches('\n').to_string()
3269        } else {
3270            reflowed
3271        }
3272    };
3273
3274    Some(ParagraphReflow {
3275        start_byte,
3276        end_byte,
3277        reflowed_text,
3278    })
3279}
3280
3281#[cfg(test)]
3282mod tests {
3283    use super::*;
3284
3285    /// Unit test for private helper function text_ends_with_abbreviation()
3286    ///
3287    /// This test stays inline because it tests a private function.
3288    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
3289    #[test]
3290    fn test_helper_function_text_ends_with_abbreviation() {
3291        // Test the helper function directly
3292        let abbreviations = get_abbreviations(&None);
3293
3294        // True cases - built-in abbreviations (titles and i.e./e.g.)
3295        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3296        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3297        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3298        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3299        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3300        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3301        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3302        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3303
3304        // False cases - NOT in built-in list (etc doesn't always have period)
3305        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3306        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3307        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3308        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3309        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3310        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
3311        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
3312        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
3313        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
3314        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
3315    }
3316
3317    #[test]
3318    fn test_is_unordered_list_marker() {
3319        // Valid unordered list markers
3320        assert!(is_unordered_list_marker("- item"));
3321        assert!(is_unordered_list_marker("* item"));
3322        assert!(is_unordered_list_marker("+ item"));
3323        assert!(is_unordered_list_marker("-")); // lone marker
3324        assert!(is_unordered_list_marker("*"));
3325        assert!(is_unordered_list_marker("+"));
3326
3327        // Not list markers
3328        assert!(!is_unordered_list_marker("---")); // horizontal rule
3329        assert!(!is_unordered_list_marker("***")); // horizontal rule
3330        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
3331        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
3332        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
3333        assert!(!is_unordered_list_marker("-word")); // no space after marker
3334        assert!(!is_unordered_list_marker("")); // empty
3335        assert!(!is_unordered_list_marker("text")); // plain text
3336        assert!(!is_unordered_list_marker("# heading")); // heading
3337    }
3338
3339    #[test]
3340    fn test_is_block_boundary() {
3341        // Block boundaries
3342        assert!(is_block_boundary("")); // empty line
3343        assert!(is_block_boundary("# Heading")); // ATX heading
3344        assert!(is_block_boundary("## Level 2")); // ATX heading
3345        assert!(is_block_boundary("```rust")); // code fence
3346        assert!(is_block_boundary("~~~")); // tilde code fence
3347        assert!(is_block_boundary("> quote")); // blockquote
3348        assert!(is_block_boundary("| cell |")); // table
3349        assert!(is_block_boundary("[link]: http://example.com")); // reference def
3350        assert!(is_block_boundary("---")); // horizontal rule
3351        assert!(is_block_boundary("***")); // horizontal rule
3352        assert!(is_block_boundary("- item")); // unordered list
3353        assert!(is_block_boundary("* item")); // unordered list
3354        assert!(is_block_boundary("+ item")); // unordered list
3355        assert!(is_block_boundary("1. item")); // ordered list
3356        assert!(is_block_boundary("10. item")); // ordered list
3357        assert!(is_block_boundary(": definition")); // definition list
3358        assert!(is_block_boundary(":::")); // div marker
3359        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
3360
3361        // NOT block boundaries (paragraph continuation)
3362        assert!(!is_block_boundary("regular text"));
3363        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
3364        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
3365        assert!(!is_block_boundary("some words here"));
3366    }
3367
3368    #[test]
3369    fn test_definition_list_boundary_in_single_line_paragraph() {
3370        // Verifies that a definition list item after a single-line paragraph
3371        // is treated as a block boundary, not merged into the paragraph
3372        let options = ReflowOptions {
3373            line_length: 80,
3374            ..Default::default()
3375        };
3376        let input = "Term\n: Definition of the term";
3377        let result = reflow_markdown(input, &options);
3378        // The definition list marker should remain on its own line
3379        assert!(
3380            result.contains(": Definition"),
3381            "Definition list item should not be merged into previous line. Got: {result:?}"
3382        );
3383        let lines: Vec<&str> = result.lines().collect();
3384        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3385        assert_eq!(lines[0], "Term");
3386        assert_eq!(lines[1], ": Definition of the term");
3387    }
3388
3389    #[test]
3390    fn test_is_paragraph_boundary() {
3391        // Core block boundary checks are inherited
3392        assert!(is_paragraph_boundary("# Heading", "# Heading"));
3393        assert!(is_paragraph_boundary("- item", "- item"));
3394        assert!(is_paragraph_boundary(":::", ":::"));
3395        assert!(is_paragraph_boundary(": definition", ": definition"));
3396
3397        // Indented code blocks (≥4 spaces or tab)
3398        assert!(is_paragraph_boundary("code", "    code"));
3399        assert!(is_paragraph_boundary("code", "\tcode"));
3400
3401        // Table rows via is_potential_table_row
3402        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3403        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
3404
3405        // Not paragraph boundaries
3406        assert!(!is_paragraph_boundary("regular text", "regular text"));
3407        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
3408    }
3409
3410    #[test]
3411    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3412        // Verifies that div markers (:::) are treated as paragraph boundaries
3413        // in reflow_paragraph_at_line, preventing reflow across div boundaries
3414        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3415        // Line 3 is the div marker — should not be reflowed
3416        let result = reflow_paragraph_at_line(content, 3, 80);
3417        assert!(result.is_none(), "Div marker line should not be reflowed");
3418    }
3419}