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