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