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 word to current line
2299                    // Only add space if: we have content AND (this isn't the first word OR original had leading space)
2300                    // AND this isn't trailing punctuation (which attaches directly)
2301                    if current_length > 0 && (i > 0 || has_leading_space) && !is_trailing_punct {
2302                        current_line.push(' ');
2303                        current_length += 1;
2304                    }
2305                    current_line.push_str(word);
2306                    current_length += word_len;
2307                }
2308            }
2309        } else if matches!(
2310            element,
2311            Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough(_)
2312        ) && element_len > options.line_length
2313        {
2314            // Italic, bold, and strikethrough with content longer than line_length need word wrapping.
2315            // Split content word-by-word, attach the opening marker to the first word
2316            // and the closing marker to the last word.
2317            let (content, marker): (&str, &str) = match element {
2318                Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2319                Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2320                Element::Strikethrough(content) => (content.as_str(), "~~"),
2321                _ => unreachable!(),
2322            };
2323
2324            let words: Vec<&str> = content.split_whitespace().collect();
2325            let n = words.len();
2326
2327            if n == 0 {
2328                // Empty span — treat as atomic
2329                let full = format!("{marker}{marker}");
2330                let full_len = display_len(&full, length_mode);
2331                if !is_adjacent_to_prev && current_length > 0 {
2332                    current_line.push(' ');
2333                    current_length += 1;
2334                }
2335                current_line.push_str(&full);
2336                current_length += full_len;
2337            } else {
2338                for (i, word) in words.iter().enumerate() {
2339                    let is_first = i == 0;
2340                    let is_last = i == n - 1;
2341                    let word_str: String = match (is_first, is_last) {
2342                        (true, true) => format!("{marker}{word}{marker}"),
2343                        (true, false) => format!("{marker}{word}"),
2344                        (false, true) => format!("{word}{marker}"),
2345                        (false, false) => word.to_string(),
2346                    };
2347                    let word_len = display_len(&word_str, length_mode);
2348
2349                    let needs_space = if is_first {
2350                        !is_adjacent_to_prev && current_length > 0
2351                    } else {
2352                        current_length > 0
2353                    };
2354
2355                    if needs_space && current_length + 1 + word_len > options.line_length {
2356                        lines.push(current_line.trim_end().to_string());
2357                        current_line = word_str;
2358                        current_length = word_len;
2359                        current_line_element_spans.clear();
2360                    } else {
2361                        if needs_space {
2362                            current_line.push(' ');
2363                            current_length += 1;
2364                        }
2365                        current_line.push_str(&word_str);
2366                        current_length += word_len;
2367                    }
2368                }
2369            }
2370        } else {
2371            // For non-text elements (code, links, references), treat as atomic units
2372            // These should never be broken across lines
2373
2374            if is_adjacent_to_prev {
2375                // Adjacent to preceding text — attach directly without space
2376                if current_length + element_len > options.line_length {
2377                    // Would exceed limit — break before the adjacent word group
2378                    // Use element-aware space search to avoid splitting inside links/code/etc.
2379                    if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2380                        let before = current_line[..last_space].trim_end().to_string();
2381                        let after = current_line[last_space + 1..].to_string();
2382                        lines.push(before);
2383                        current_line = format!("{after}{element_str}");
2384                        current_length = display_len(&current_line, length_mode);
2385                        current_line_element_spans.clear();
2386                        // Record the element span in the new current_line
2387                        let start = after.len();
2388                        current_line_element_spans.push((start, start + element_str.len()));
2389                    } else {
2390                        // No safe space to break at — accept the long line
2391                        let start = current_line.len();
2392                        current_line.push_str(&element_str);
2393                        current_length += element_len;
2394                        current_line_element_spans.push((start, current_line.len()));
2395                    }
2396                } else {
2397                    let start = current_line.len();
2398                    current_line.push_str(&element_str);
2399                    current_length += element_len;
2400                    current_line_element_spans.push((start, current_line.len()));
2401                }
2402            } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2403                // Not adjacent, would exceed — start new line
2404                lines.push(current_line.trim().to_string());
2405                current_line.clone_from(&element_str);
2406                current_length = element_len;
2407                current_line_element_spans.clear();
2408                current_line_element_spans.push((0, element_str.len()));
2409            } else {
2410                // Not adjacent, fits — add with space
2411                let ends_with_opener =
2412                    current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2413                if current_length > 0 && !ends_with_opener {
2414                    current_line.push(' ');
2415                    current_length += 1;
2416                }
2417                let start = current_line.len();
2418                current_line.push_str(&element_str);
2419                current_length += element_len;
2420                current_line_element_spans.push((start, current_line.len()));
2421            }
2422        }
2423    }
2424
2425    // Don't forget the last line
2426    if !current_line.is_empty() {
2427        lines.push(current_line.trim_end().to_string());
2428    }
2429
2430    lines
2431}
2432
2433/// Reflow markdown content preserving structure
2434pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2435    let lines: Vec<&str> = content.lines().collect();
2436    let mut result = Vec::new();
2437    let mut i = 0;
2438
2439    while i < lines.len() {
2440        let line = lines[i];
2441        let trimmed = line.trim();
2442
2443        // Preserve empty lines
2444        if trimmed.is_empty() {
2445            result.push(String::new());
2446            i += 1;
2447            continue;
2448        }
2449
2450        // Preserve headings as-is
2451        if trimmed.starts_with('#') {
2452            result.push(line.to_string());
2453            i += 1;
2454            continue;
2455        }
2456
2457        // Preserve Quarto/Pandoc div markers (:::) as-is
2458        if trimmed.starts_with(":::") {
2459            result.push(line.to_string());
2460            i += 1;
2461            continue;
2462        }
2463
2464        // Preserve fenced code blocks
2465        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2466            result.push(line.to_string());
2467            i += 1;
2468            // Copy lines until closing fence
2469            while i < lines.len() {
2470                result.push(lines[i].to_string());
2471                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2472                    i += 1;
2473                    break;
2474                }
2475                i += 1;
2476            }
2477            continue;
2478        }
2479
2480        // Preserve indented code blocks (4+ columns accounting for tab expansion)
2481        if calculate_indentation_width_default(line) >= 4 {
2482            // Collect all consecutive indented lines
2483            result.push(line.to_string());
2484            i += 1;
2485            while i < lines.len() {
2486                let next_line = lines[i];
2487                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
2488                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2489                    result.push(next_line.to_string());
2490                    i += 1;
2491                } else {
2492                    break;
2493                }
2494            }
2495            continue;
2496        }
2497
2498        // Preserve block quotes (but reflow their content)
2499        if trimmed.starts_with('>') {
2500            // find() returns byte position which is correct for str slicing
2501            // The unwrap is safe because we already verified trimmed starts with '>'
2502            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2503            let quote_prefix = line[0..=gt_pos].to_string();
2504            let quote_content = &line[quote_prefix.len()..].trim_start();
2505
2506            let reflowed = reflow_line(quote_content, options);
2507            for reflowed_line in &reflowed {
2508                result.push(format!("{quote_prefix} {reflowed_line}"));
2509            }
2510            i += 1;
2511            continue;
2512        }
2513
2514        // Preserve horizontal rules first (before checking for lists)
2515        if is_horizontal_rule(trimmed) {
2516            result.push(line.to_string());
2517            i += 1;
2518            continue;
2519        }
2520
2521        // Preserve lists (but not horizontal rules)
2522        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2523            // Find the list marker and preserve indentation
2524            let indent = line.len() - line.trim_start().len();
2525            let indent_str = " ".repeat(indent);
2526
2527            // For numbered lists, find the period and the space after it
2528            // For bullet lists, find the marker and the space after it
2529            let mut marker_end = indent;
2530            let mut content_start = indent;
2531
2532            if trimmed.chars().next().is_some_and(char::is_numeric) {
2533                // Numbered list: find the period
2534                if let Some(period_pos) = line[indent..].find('.') {
2535                    marker_end = indent + period_pos + 1; // Include the period
2536                    content_start = marker_end;
2537                    // Skip any spaces after the period to find content start
2538                    // Use byte-based check since content_start is a byte index
2539                    // This is safe because space is ASCII (single byte)
2540                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2541                        content_start += 1;
2542                    }
2543                }
2544            } else {
2545                // Bullet list: marker is single character
2546                marker_end = indent + 1; // Just the marker character
2547                content_start = marker_end;
2548                // Skip any spaces after the marker
2549                // Use byte-based check since content_start is a byte index
2550                // This is safe because space is ASCII (single byte)
2551                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2552                    content_start += 1;
2553                }
2554            }
2555
2556            // Minimum indent for continuation lines (based on list marker, before checkbox)
2557            let min_continuation_indent = content_start;
2558
2559            // Detect checkbox/task list markers: [ ], [x], [X]
2560            // GFM task lists work with both unordered and ordered lists
2561            let rest = &line[content_start..];
2562            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2563                marker_end = content_start + 3; // Include the checkbox `[ ]`
2564                content_start += 4; // Skip past `[ ] `
2565            }
2566
2567            let marker = &line[indent..marker_end];
2568
2569            // Collect all content for this list item (including continuation lines)
2570            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
2571            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2572            i += 1;
2573
2574            // Collect continuation lines (indented lines that are part of this list item)
2575            // Use the base marker indent (not checkbox-extended) for collection,
2576            // since users may indent continuations to the bullet level, not the checkbox level
2577            while i < lines.len() {
2578                let next_line = lines[i];
2579                let next_trimmed = next_line.trim();
2580
2581                // Stop if we hit an empty line or another list item or special block
2582                if is_block_boundary(next_trimmed) {
2583                    break;
2584                }
2585
2586                // Check if this line is indented (continuation of list item)
2587                let next_indent = next_line.len() - next_line.trim_start().len();
2588                if next_indent >= min_continuation_indent {
2589                    // This is a continuation line - add its content
2590                    // Preserve hard breaks while trimming excessive whitespace
2591                    let trimmed_start = next_line.trim_start();
2592                    list_content.push(trim_preserving_hard_break(trimmed_start));
2593                    i += 1;
2594                } else {
2595                    // Not indented enough, not part of this list item
2596                    break;
2597                }
2598            }
2599
2600            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
2601            // Hard breaks should prevent joining with the next line
2602            let combined_content = if options.preserve_breaks {
2603                list_content[0].clone()
2604            } else {
2605                // Check if any lines have hard breaks - if so, preserve the structure
2606                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2607                if has_hard_breaks {
2608                    // Don't join lines with hard breaks - keep them separate with newlines
2609                    list_content.join("\n")
2610                } else {
2611                    // No hard breaks, safe to join with spaces
2612                    list_content.join(" ")
2613                }
2614            };
2615
2616            // Calculate the proper indentation for continuation lines
2617            let trimmed_marker = marker;
2618            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2619                // Cap the relative indent (past the nesting level) to max_indent,
2620                // then add back the nesting indent so nested items stay correct
2621                indent + (content_start - indent).min(max_indent)
2622            } else {
2623                content_start
2624            };
2625
2626            // Adjust line length to account for list marker and space
2627            let prefix_length = indent + trimmed_marker.len() + 1;
2628
2629            // Create adjusted options with reduced line length
2630            let adjusted_options = ReflowOptions {
2631                line_length: options.line_length.saturating_sub(prefix_length),
2632                ..options.clone()
2633            };
2634
2635            let reflowed = reflow_line(&combined_content, &adjusted_options);
2636            for (j, reflowed_line) in reflowed.iter().enumerate() {
2637                if j == 0 {
2638                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2639                } else {
2640                    // Continuation lines aligned with text after marker
2641                    let continuation_indent = " ".repeat(continuation_spaces);
2642                    result.push(format!("{continuation_indent}{reflowed_line}"));
2643                }
2644            }
2645            continue;
2646        }
2647
2648        // Preserve tables
2649        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2650            result.push(line.to_string());
2651            i += 1;
2652            continue;
2653        }
2654
2655        // Preserve reference definitions
2656        if trimmed.starts_with('[') && line.contains("]:") {
2657            result.push(line.to_string());
2658            i += 1;
2659            continue;
2660        }
2661
2662        // Preserve definition list items (extended markdown)
2663        if is_definition_list_item(trimmed) {
2664            result.push(line.to_string());
2665            i += 1;
2666            continue;
2667        }
2668
2669        // Check if this is a single line that doesn't need processing
2670        let mut is_single_line_paragraph = true;
2671        if i + 1 < lines.len() {
2672            let next_trimmed = lines[i + 1].trim();
2673            // Check if next line continues this paragraph
2674            if !is_block_boundary(next_trimmed) {
2675                is_single_line_paragraph = false;
2676            }
2677        }
2678
2679        // If it's a single line that fits, just add it as-is
2680        if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2681            result.push(line.to_string());
2682            i += 1;
2683            continue;
2684        }
2685
2686        // For regular paragraphs, collect consecutive lines
2687        let mut paragraph_parts = Vec::new();
2688        let mut current_part = vec![line];
2689        i += 1;
2690
2691        // If preserve_breaks is true, treat each line separately
2692        if options.preserve_breaks {
2693            // Don't collect consecutive lines - just reflow this single line
2694            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2695                Some("\\")
2696            } else if line.ends_with("  ") {
2697                Some("  ")
2698            } else {
2699                None
2700            };
2701            let reflowed = reflow_line(line, options);
2702
2703            // Preserve hard breaks (two trailing spaces or backslash)
2704            if let Some(break_marker) = hard_break_type {
2705                if !reflowed.is_empty() {
2706                    let mut reflowed_with_break = reflowed;
2707                    let last_idx = reflowed_with_break.len() - 1;
2708                    if !has_hard_break(&reflowed_with_break[last_idx]) {
2709                        reflowed_with_break[last_idx].push_str(break_marker);
2710                    }
2711                    result.extend(reflowed_with_break);
2712                }
2713            } else {
2714                result.extend(reflowed);
2715            }
2716        } else {
2717            // Original behavior: collect consecutive lines into a paragraph
2718            while i < lines.len() {
2719                let prev_line = if !current_part.is_empty() {
2720                    current_part.last().unwrap()
2721                } else {
2722                    ""
2723                };
2724                let next_line = lines[i];
2725                let next_trimmed = next_line.trim();
2726
2727                // Stop at empty lines or special blocks
2728                if is_block_boundary(next_trimmed) {
2729                    break;
2730                }
2731
2732                // Check if previous line ends with hard break (two spaces or backslash)
2733                // or is a complete sentence in sentence_per_line mode
2734                let prev_trimmed = prev_line.trim();
2735                let abbreviations = get_abbreviations(&options.abbreviations);
2736                let ends_with_sentence = (prev_trimmed.ends_with('.')
2737                    || prev_trimmed.ends_with('!')
2738                    || prev_trimmed.ends_with('?')
2739                    || prev_trimmed.ends_with(".*")
2740                    || prev_trimmed.ends_with("!*")
2741                    || prev_trimmed.ends_with("?*")
2742                    || prev_trimmed.ends_with("._")
2743                    || prev_trimmed.ends_with("!_")
2744                    || prev_trimmed.ends_with("?_")
2745                    // Quote-terminated sentences (straight and curly quotes)
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                    || prev_trimmed.ends_with(".\u{201D}")
2753                    || prev_trimmed.ends_with("!\u{201D}")
2754                    || prev_trimmed.ends_with("?\u{201D}")
2755                    || prev_trimmed.ends_with(".\u{2019}")
2756                    || prev_trimmed.ends_with("!\u{2019}")
2757                    || prev_trimmed.ends_with("?\u{2019}"))
2758                    && !text_ends_with_abbreviation(
2759                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2760                        &abbreviations,
2761                    );
2762
2763                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2764                    // Start a new part after hard break or complete sentence
2765                    paragraph_parts.push(current_part.join(" "));
2766                    current_part = vec![next_line];
2767                } else {
2768                    current_part.push(next_line);
2769                }
2770                i += 1;
2771            }
2772
2773            // Add the last part
2774            if !current_part.is_empty() {
2775                if current_part.len() == 1 {
2776                    // Single line, don't add trailing space
2777                    paragraph_parts.push(current_part[0].to_string());
2778                } else {
2779                    paragraph_parts.push(current_part.join(" "));
2780                }
2781            }
2782
2783            // Reflow each part separately, preserving hard breaks
2784            for (j, part) in paragraph_parts.iter().enumerate() {
2785                let reflowed = reflow_line(part, options);
2786                result.extend(reflowed);
2787
2788                // Preserve hard break by ensuring last line of part ends with hard break marker
2789                // Use two spaces as the default hard break format for reflows
2790                // But don't add hard breaks in sentence_per_line mode - lines are already separate
2791                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2792                    let last_idx = result.len() - 1;
2793                    if !has_hard_break(&result[last_idx]) {
2794                        result[last_idx].push_str("  ");
2795                    }
2796                }
2797            }
2798        }
2799    }
2800
2801    // Preserve trailing newline if the original content had one
2802    let result_text = result.join("\n");
2803    if content.ends_with('\n') && !result_text.ends_with('\n') {
2804        format!("{result_text}\n")
2805    } else {
2806        result_text
2807    }
2808}
2809
2810/// Information about a reflowed paragraph
2811#[derive(Debug, Clone)]
2812pub struct ParagraphReflow {
2813    /// Starting byte offset of the paragraph in the original content
2814    pub start_byte: usize,
2815    /// Ending byte offset of the paragraph in the original content
2816    pub end_byte: usize,
2817    /// The reflowed text for this paragraph
2818    pub reflowed_text: String,
2819}
2820
2821/// A collected blockquote line used for style-preserving reflow.
2822///
2823/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
2824/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
2825/// rather than constructing the struct directly.
2826#[derive(Debug, Clone)]
2827pub struct BlockquoteLineData {
2828    /// Trimmed content without the `> ` prefix.
2829    pub(crate) content: String,
2830    /// Whether this line carries an explicit blockquote marker.
2831    pub(crate) is_explicit: bool,
2832    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
2833    pub(crate) prefix: Option<String>,
2834}
2835
2836impl BlockquoteLineData {
2837    /// Create an explicit (marker-bearing) blockquote line.
2838    pub fn explicit(content: String, prefix: String) -> Self {
2839        Self {
2840            content,
2841            is_explicit: true,
2842            prefix: Some(prefix),
2843        }
2844    }
2845
2846    /// Create a lazy continuation line (no blockquote marker).
2847    pub fn lazy(content: String) -> Self {
2848        Self {
2849            content,
2850            is_explicit: false,
2851            prefix: None,
2852        }
2853    }
2854}
2855
2856/// Style for blockquote continuation lines after reflow.
2857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2858pub enum BlockquoteContinuationStyle {
2859    Explicit,
2860    Lazy,
2861}
2862
2863/// Determine the continuation style for a blockquote paragraph from its collected lines.
2864///
2865/// The first line is always explicit (it carries the marker), so only continuation
2866/// lines (index 1+) are counted. Ties resolve to `Explicit`.
2867///
2868/// When the slice has only one element (no continuation lines to inspect), both
2869/// counts are zero and the tie-breaking rule returns `Explicit`.
2870pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2871    let mut explicit_count = 0usize;
2872    let mut lazy_count = 0usize;
2873
2874    for line in lines.iter().skip(1) {
2875        if line.is_explicit {
2876            explicit_count += 1;
2877        } else {
2878            lazy_count += 1;
2879        }
2880    }
2881
2882    if explicit_count > 0 && lazy_count == 0 {
2883        BlockquoteContinuationStyle::Explicit
2884    } else if lazy_count > 0 && explicit_count == 0 {
2885        BlockquoteContinuationStyle::Lazy
2886    } else if explicit_count >= lazy_count {
2887        BlockquoteContinuationStyle::Explicit
2888    } else {
2889        BlockquoteContinuationStyle::Lazy
2890    }
2891}
2892
2893/// Determine the dominant blockquote prefix for a paragraph.
2894///
2895/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
2896/// first appearance. Falls back to `fallback` when no explicit lines are present.
2897pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2898    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2899
2900    for (idx, line) in lines.iter().enumerate() {
2901        let Some(prefix) = line.prefix.as_ref() else {
2902            continue;
2903        };
2904        counts
2905            .entry(prefix.clone())
2906            .and_modify(|entry| entry.0 += 1)
2907            .or_insert((1, idx));
2908    }
2909
2910    counts
2911        .into_iter()
2912        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2913            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2914        })
2915        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2916}
2917
2918/// Whether a reflowed blockquote content line must carry an explicit prefix.
2919///
2920/// Lines that would start a new block structure (headings, fences, lists, etc.)
2921/// cannot safely use lazy continuation syntax.
2922pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2923    let trimmed = content_line.trim_start();
2924    trimmed.starts_with('>')
2925        || trimmed.starts_with('#')
2926        || trimmed.starts_with("```")
2927        || trimmed.starts_with("~~~")
2928        || is_unordered_list_marker(trimmed)
2929        || is_numbered_list_item(trimmed)
2930        || is_horizontal_rule(trimmed)
2931        || is_definition_list_item(trimmed)
2932        || (trimmed.starts_with('[') && trimmed.contains("]:"))
2933        || trimmed.starts_with(":::")
2934        || (trimmed.starts_with('<')
2935            && !trimmed.starts_with("<http")
2936            && !trimmed.starts_with("<https")
2937            && !trimmed.starts_with("<mailto:"))
2938}
2939
2940/// Reflow blockquote content lines and apply continuation style.
2941///
2942/// Segments separated by hard breaks are reflowed independently. The output lines
2943/// receive blockquote prefixes according to `continuation_style`: the first line and
2944/// any line that would start a new block structure always get an explicit prefix;
2945/// other lines follow the detected style.
2946///
2947/// Returns the styled, reflowed lines (without a trailing newline).
2948pub fn reflow_blockquote_content(
2949    lines: &[BlockquoteLineData],
2950    explicit_prefix: &str,
2951    continuation_style: BlockquoteContinuationStyle,
2952    options: &ReflowOptions,
2953) -> Vec<String> {
2954    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2955    let segments = split_into_segments_strs(&content_strs);
2956    let mut reflowed_content_lines: Vec<String> = Vec::new();
2957
2958    for segment in segments {
2959        let hard_break_type = segment.last().and_then(|&line| {
2960            let line = line.strip_suffix('\r').unwrap_or(line);
2961            if line.ends_with('\\') {
2962                Some("\\")
2963            } else if line.ends_with("  ") {
2964                Some("  ")
2965            } else {
2966                None
2967            }
2968        });
2969
2970        let pieces: Vec<&str> = segment
2971            .iter()
2972            .map(|&line| {
2973                if let Some(l) = line.strip_suffix('\\') {
2974                    l.trim_end()
2975                } else if let Some(l) = line.strip_suffix("  ") {
2976                    l.trim_end()
2977                } else {
2978                    line.trim_end()
2979                }
2980            })
2981            .collect();
2982
2983        let segment_text = pieces.join(" ");
2984        let segment_text = segment_text.trim();
2985        if segment_text.is_empty() {
2986            continue;
2987        }
2988
2989        let mut reflowed = reflow_line(segment_text, options);
2990        if let Some(break_marker) = hard_break_type
2991            && !reflowed.is_empty()
2992        {
2993            let last_idx = reflowed.len() - 1;
2994            if !has_hard_break(&reflowed[last_idx]) {
2995                reflowed[last_idx].push_str(break_marker);
2996            }
2997        }
2998        reflowed_content_lines.extend(reflowed);
2999    }
3000
3001    let mut styled_lines: Vec<String> = Vec::new();
3002    for (idx, line) in reflowed_content_lines.iter().enumerate() {
3003        let force_explicit = idx == 0
3004            || continuation_style == BlockquoteContinuationStyle::Explicit
3005            || should_force_explicit_blockquote_line(line);
3006        if force_explicit {
3007            styled_lines.push(format!("{explicit_prefix}{line}"));
3008        } else {
3009            styled_lines.push(line.clone());
3010        }
3011    }
3012
3013    styled_lines
3014}
3015
3016fn is_blockquote_content_boundary(content: &str) -> bool {
3017    let trimmed = content.trim();
3018    trimmed.is_empty()
3019        || is_block_boundary(trimmed)
3020        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3021        || trimmed.starts_with(":::")
3022        || crate::utils::is_template_directive_only(content)
3023        || is_standalone_attr_list(content)
3024        || is_snippet_block_delimiter(content)
3025}
3026
3027fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3028    let mut segments = Vec::new();
3029    let mut current = Vec::new();
3030
3031    for &line in lines {
3032        current.push(line);
3033        if has_hard_break(line) {
3034            segments.push(current);
3035            current = Vec::new();
3036        }
3037    }
3038
3039    if !current.is_empty() {
3040        segments.push(current);
3041    }
3042
3043    segments
3044}
3045
3046fn reflow_blockquote_paragraph_at_line(
3047    content: &str,
3048    lines: &[&str],
3049    target_idx: usize,
3050    options: &ReflowOptions,
3051) -> Option<ParagraphReflow> {
3052    let mut anchor_idx = target_idx;
3053    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3054        parsed.nesting_level
3055    } else {
3056        let mut found = None;
3057        let mut idx = target_idx;
3058        loop {
3059            if lines[idx].trim().is_empty() {
3060                break;
3061            }
3062            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3063                found = Some((idx, parsed.nesting_level));
3064                break;
3065            }
3066            if idx == 0 {
3067                break;
3068            }
3069            idx -= 1;
3070        }
3071        let (idx, level) = found?;
3072        anchor_idx = idx;
3073        level
3074    };
3075
3076    // Expand backward to capture prior quote content at the same nesting level.
3077    let mut para_start = anchor_idx;
3078    while para_start > 0 {
3079        let prev_idx = para_start - 1;
3080        let prev_line = lines[prev_idx];
3081
3082        if prev_line.trim().is_empty() {
3083            break;
3084        }
3085
3086        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3087            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3088                break;
3089            }
3090            para_start = prev_idx;
3091            continue;
3092        }
3093
3094        let prev_lazy = prev_line.trim_start();
3095        if is_blockquote_content_boundary(prev_lazy) {
3096            break;
3097        }
3098        para_start = prev_idx;
3099    }
3100
3101    // Lazy continuation cannot precede the first explicit marker.
3102    while para_start < lines.len() {
3103        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3104            para_start += 1;
3105            continue;
3106        };
3107        target_level = parsed.nesting_level;
3108        break;
3109    }
3110
3111    if para_start >= lines.len() || para_start > target_idx {
3112        return None;
3113    }
3114
3115    // Collect explicit lines at target level and lazy continuation lines.
3116    // Each entry is (original_line_idx, BlockquoteLineData).
3117    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3118    let mut idx = para_start;
3119    while idx < lines.len() {
3120        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3121            break;
3122        }
3123
3124        let line = lines[idx];
3125        if line.trim().is_empty() {
3126            break;
3127        }
3128
3129        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3130            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3131                break;
3132            }
3133            collected.push((
3134                idx,
3135                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3136            ));
3137            idx += 1;
3138            continue;
3139        }
3140
3141        let lazy_content = line.trim_start();
3142        if is_blockquote_content_boundary(lazy_content) {
3143            break;
3144        }
3145
3146        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3147        idx += 1;
3148    }
3149
3150    if collected.is_empty() {
3151        return None;
3152    }
3153
3154    let para_end = collected[collected.len() - 1].0;
3155    if target_idx < para_start || target_idx > para_end {
3156        return None;
3157    }
3158
3159    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3160
3161    let fallback_prefix = line_data
3162        .iter()
3163        .find_map(|d| d.prefix.clone())
3164        .unwrap_or_else(|| "> ".to_string());
3165    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3166    let continuation_style = blockquote_continuation_style(&line_data);
3167
3168    let adjusted_line_length = options
3169        .line_length
3170        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3171        .max(1);
3172
3173    let adjusted_options = ReflowOptions {
3174        line_length: adjusted_line_length,
3175        ..options.clone()
3176    };
3177
3178    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3179
3180    if styled_lines.is_empty() {
3181        return None;
3182    }
3183
3184    // Calculate byte offsets.
3185    let mut start_byte = 0;
3186    for line in lines.iter().take(para_start) {
3187        start_byte += line.len() + 1;
3188    }
3189
3190    let mut end_byte = start_byte;
3191    for line in lines.iter().take(para_end + 1).skip(para_start) {
3192        end_byte += line.len() + 1;
3193    }
3194
3195    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3196    if !includes_trailing_newline {
3197        end_byte -= 1;
3198    }
3199
3200    let reflowed_joined = styled_lines.join("\n");
3201    let reflowed_text = if includes_trailing_newline {
3202        if reflowed_joined.ends_with('\n') {
3203            reflowed_joined
3204        } else {
3205            format!("{reflowed_joined}\n")
3206        }
3207    } else if reflowed_joined.ends_with('\n') {
3208        reflowed_joined.trim_end_matches('\n').to_string()
3209    } else {
3210        reflowed_joined
3211    };
3212
3213    Some(ParagraphReflow {
3214        start_byte,
3215        end_byte,
3216        reflowed_text,
3217    })
3218}
3219
3220/// Reflow a single paragraph at the specified line number
3221///
3222/// This function finds the paragraph containing the given line number,
3223/// reflows it according to the specified line length, and returns
3224/// information about the paragraph location and its reflowed text.
3225///
3226/// # Arguments
3227///
3228/// * `content` - The full document content
3229/// * `line_number` - The 1-based line number within the paragraph to reflow
3230/// * `line_length` - The target line length for reflowing
3231///
3232/// # Returns
3233///
3234/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
3235/// or `None` if the line number is out of bounds or the content at that
3236/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
3237pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3238    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3239}
3240
3241/// Reflow a paragraph at the given line with a specific length mode.
3242pub fn reflow_paragraph_at_line_with_mode(
3243    content: &str,
3244    line_number: usize,
3245    line_length: usize,
3246    length_mode: ReflowLengthMode,
3247) -> Option<ParagraphReflow> {
3248    let options = ReflowOptions {
3249        line_length,
3250        length_mode,
3251        ..Default::default()
3252    };
3253    reflow_paragraph_at_line_with_options(content, line_number, &options)
3254}
3255
3256/// Reflow a paragraph at the given line using the provided options.
3257///
3258/// This is the canonical implementation used by both the rule's fix mode and the
3259/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
3260/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
3261///
3262/// # Returns
3263///
3264/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
3265/// if the line is out of bounds or sits inside a non-reflow-able construct.
3266pub fn reflow_paragraph_at_line_with_options(
3267    content: &str,
3268    line_number: usize,
3269    options: &ReflowOptions,
3270) -> Option<ParagraphReflow> {
3271    if line_number == 0 {
3272        return None;
3273    }
3274
3275    let lines: Vec<&str> = content.lines().collect();
3276
3277    // Check if line number is valid (1-based)
3278    if line_number > lines.len() {
3279        return None;
3280    }
3281
3282    let target_idx = line_number - 1; // Convert to 0-based
3283    let target_line = lines[target_idx];
3284    let trimmed = target_line.trim();
3285
3286    // Handle blockquote paragraphs (including lazy continuation lines) with
3287    // style-preserving output.
3288    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3289        return Some(blockquote_reflow);
3290    }
3291
3292    // Don't reflow special blocks
3293    if is_paragraph_boundary(trimmed, target_line) {
3294        return None;
3295    }
3296
3297    // Find paragraph start - scan backward until blank line or special block
3298    let mut para_start = target_idx;
3299    while para_start > 0 {
3300        let prev_idx = para_start - 1;
3301        let prev_line = lines[prev_idx];
3302        let prev_trimmed = prev_line.trim();
3303
3304        // Stop at blank line or special blocks
3305        if is_paragraph_boundary(prev_trimmed, prev_line) {
3306            break;
3307        }
3308
3309        para_start = prev_idx;
3310    }
3311
3312    // Find paragraph end - scan forward until blank line or special block
3313    let mut para_end = target_idx;
3314    while para_end + 1 < lines.len() {
3315        let next_idx = para_end + 1;
3316        let next_line = lines[next_idx];
3317        let next_trimmed = next_line.trim();
3318
3319        // Stop at blank line or special blocks
3320        if is_paragraph_boundary(next_trimmed, next_line) {
3321            break;
3322        }
3323
3324        para_end = next_idx;
3325    }
3326
3327    // Extract paragraph lines
3328    let paragraph_lines = &lines[para_start..=para_end];
3329
3330    // Calculate byte offsets
3331    let mut start_byte = 0;
3332    for line in lines.iter().take(para_start) {
3333        start_byte += line.len() + 1; // +1 for newline
3334    }
3335
3336    let mut end_byte = start_byte;
3337    for line in paragraph_lines {
3338        end_byte += line.len() + 1; // +1 for newline
3339    }
3340
3341    // Track whether the byte range includes a trailing newline
3342    // (it doesn't if this is the last line and the file doesn't end with newline)
3343    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3344
3345    // Adjust end_byte if the last line doesn't have a newline
3346    if !includes_trailing_newline {
3347        end_byte -= 1;
3348    }
3349
3350    // Join paragraph lines and reflow
3351    let paragraph_text = paragraph_lines.join("\n");
3352
3353    // Reflow the paragraph using reflow_markdown to handle it properly
3354    let reflowed = reflow_markdown(&paragraph_text, options);
3355
3356    // Ensure reflowed text matches whether the byte range includes a trailing newline
3357    // This is critical: if the range includes a newline, the replacement must too,
3358    // otherwise the next line will get appended to the reflowed paragraph
3359    let reflowed_text = if includes_trailing_newline {
3360        // Range includes newline - ensure reflowed text has one
3361        if reflowed.ends_with('\n') {
3362            reflowed
3363        } else {
3364            format!("{reflowed}\n")
3365        }
3366    } else {
3367        // Range doesn't include newline - ensure reflowed text doesn't have one
3368        if reflowed.ends_with('\n') {
3369            reflowed.trim_end_matches('\n').to_string()
3370        } else {
3371            reflowed
3372        }
3373    };
3374
3375    Some(ParagraphReflow {
3376        start_byte,
3377        end_byte,
3378        reflowed_text,
3379    })
3380}
3381
3382#[cfg(test)]
3383mod tests {
3384    use super::*;
3385
3386    /// Unit test for private helper function text_ends_with_abbreviation()
3387    ///
3388    /// This test stays inline because it tests a private function.
3389    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
3390    #[test]
3391    fn test_helper_function_text_ends_with_abbreviation() {
3392        // Test the helper function directly
3393        let abbreviations = get_abbreviations(&None);
3394
3395        // True cases - built-in abbreviations (titles and i.e./e.g.)
3396        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3397        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3398        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3399        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3400        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3401        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3402        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3403        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3404
3405        // False cases - NOT in built-in list (etc doesn't always have period)
3406        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3407        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3408        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3409        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3410        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3411        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
3412        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
3413        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
3414        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
3415        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
3416    }
3417
3418    #[test]
3419    fn test_is_unordered_list_marker() {
3420        // Valid unordered list markers
3421        assert!(is_unordered_list_marker("- item"));
3422        assert!(is_unordered_list_marker("* item"));
3423        assert!(is_unordered_list_marker("+ item"));
3424        assert!(is_unordered_list_marker("-")); // lone marker
3425        assert!(is_unordered_list_marker("*"));
3426        assert!(is_unordered_list_marker("+"));
3427
3428        // Not list markers
3429        assert!(!is_unordered_list_marker("---")); // horizontal rule
3430        assert!(!is_unordered_list_marker("***")); // horizontal rule
3431        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
3432        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
3433        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
3434        assert!(!is_unordered_list_marker("-word")); // no space after marker
3435        assert!(!is_unordered_list_marker("")); // empty
3436        assert!(!is_unordered_list_marker("text")); // plain text
3437        assert!(!is_unordered_list_marker("# heading")); // heading
3438    }
3439
3440    #[test]
3441    fn test_is_block_boundary() {
3442        // Block boundaries
3443        assert!(is_block_boundary("")); // empty line
3444        assert!(is_block_boundary("# Heading")); // ATX heading
3445        assert!(is_block_boundary("## Level 2")); // ATX heading
3446        assert!(is_block_boundary("```rust")); // code fence
3447        assert!(is_block_boundary("~~~")); // tilde code fence
3448        assert!(is_block_boundary("> quote")); // blockquote
3449        assert!(is_block_boundary("| cell |")); // table
3450        assert!(is_block_boundary("[link]: http://example.com")); // reference def
3451        assert!(is_block_boundary("---")); // horizontal rule
3452        assert!(is_block_boundary("***")); // horizontal rule
3453        assert!(is_block_boundary("- item")); // unordered list
3454        assert!(is_block_boundary("* item")); // unordered list
3455        assert!(is_block_boundary("+ item")); // unordered list
3456        assert!(is_block_boundary("1. item")); // ordered list
3457        assert!(is_block_boundary("10. item")); // ordered list
3458        assert!(is_block_boundary(": definition")); // definition list
3459        assert!(is_block_boundary(":::")); // div marker
3460        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
3461
3462        // NOT block boundaries (paragraph continuation)
3463        assert!(!is_block_boundary("regular text"));
3464        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
3465        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
3466        assert!(!is_block_boundary("some words here"));
3467    }
3468
3469    #[test]
3470    fn test_definition_list_boundary_in_single_line_paragraph() {
3471        // Verifies that a definition list item after a single-line paragraph
3472        // is treated as a block boundary, not merged into the paragraph
3473        let options = ReflowOptions {
3474            line_length: 80,
3475            ..Default::default()
3476        };
3477        let input = "Term\n: Definition of the term";
3478        let result = reflow_markdown(input, &options);
3479        // The definition list marker should remain on its own line
3480        assert!(
3481            result.contains(": Definition"),
3482            "Definition list item should not be merged into previous line. Got: {result:?}"
3483        );
3484        let lines: Vec<&str> = result.lines().collect();
3485        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3486        assert_eq!(lines[0], "Term");
3487        assert_eq!(lines[1], ": Definition of the term");
3488    }
3489
3490    #[test]
3491    fn test_is_paragraph_boundary() {
3492        // Core block boundary checks are inherited
3493        assert!(is_paragraph_boundary("# Heading", "# Heading"));
3494        assert!(is_paragraph_boundary("- item", "- item"));
3495        assert!(is_paragraph_boundary(":::", ":::"));
3496        assert!(is_paragraph_boundary(": definition", ": definition"));
3497
3498        // Indented code blocks (≥4 spaces or tab)
3499        assert!(is_paragraph_boundary("code", "    code"));
3500        assert!(is_paragraph_boundary("code", "\tcode"));
3501
3502        // Table rows via is_potential_table_row
3503        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3504        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
3505
3506        // Not paragraph boundaries
3507        assert!(!is_paragraph_boundary("regular text", "regular text"));
3508        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
3509    }
3510
3511    #[test]
3512    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3513        // Verifies that div markers (:::) are treated as paragraph boundaries
3514        // in reflow_paragraph_at_line, preventing reflow across div boundaries
3515        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3516        // Line 3 is the div marker — should not be reflowed
3517        let result = reflow_paragraph_at_line(content, 3, 80);
3518        assert!(result.is_none(), "Div marker line should not be reflowed");
3519    }
3520}