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/// Recursively cascade-split a line that exceeds line_length.
1993/// Tries clause punctuation first, then break-words, then word wrap.
1994fn cascade_split_line(
1995    text: &str,
1996    line_length: usize,
1997    abbreviations: &Option<Vec<String>>,
1998    length_mode: ReflowLengthMode,
1999    attr_lists: bool,
2000    myst_roles: bool,
2001    defined_references: Option<&HashSet<String>>,
2002) -> Vec<String> {
2003    if line_length == 0 || display_len(text, length_mode) <= line_length {
2004        return vec![text.to_string()];
2005    }
2006
2007    let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2008    let element_spans = compute_element_spans(&elements);
2009
2010    // Try parenthetical boundary split (before clause punctuation so that
2011    // multi-word parentheticals are kept intact as semantic units)
2012    if let Some((first, rest)) = split_at_parenthetical(text, line_length, &element_spans, length_mode) {
2013        let mut result = vec![first];
2014        result.extend(cascade_split_line(
2015            &rest,
2016            line_length,
2017            abbreviations,
2018            length_mode,
2019            attr_lists,
2020            myst_roles,
2021            defined_references,
2022        ));
2023        return result;
2024    }
2025
2026    // Try clause punctuation split
2027    if let Some((first, rest)) = split_at_clause_punctuation(text, line_length, &element_spans, length_mode) {
2028        let mut result = vec![first];
2029        result.extend(cascade_split_line(
2030            &rest,
2031            line_length,
2032            abbreviations,
2033            length_mode,
2034            attr_lists,
2035            myst_roles,
2036            defined_references,
2037        ));
2038        return result;
2039    }
2040
2041    // Try break-word split
2042    if let Some((first, rest)) = split_at_break_word(text, line_length, &element_spans, length_mode) {
2043        let mut result = vec![first];
2044        result.extend(cascade_split_line(
2045            &rest,
2046            line_length,
2047            abbreviations,
2048            length_mode,
2049            attr_lists,
2050            myst_roles,
2051            defined_references,
2052        ));
2053        return result;
2054    }
2055
2056    // Fallback: word wrap using existing reflow_elements
2057    let options = ReflowOptions {
2058        line_length,
2059        break_on_sentences: false,
2060        preserve_breaks: false,
2061        sentence_per_line: false,
2062        semantic_line_breaks: false,
2063        abbreviations: abbreviations.clone(),
2064        length_mode,
2065        attr_lists,
2066        myst_roles,
2067        require_sentence_capital: true,
2068        max_list_continuation_indent: None,
2069        // Unused here: this fallback arranges already-parsed elements and never
2070        // re-parses links, so shortcut definedness is never consulted.
2071        defined_references: None,
2072    };
2073    reflow_elements(&elements, &options)
2074}
2075
2076/// Reflow elements using semantic line breaks strategy:
2077/// 1. Split at sentence boundaries (always)
2078/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
2079fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2080    // Step 1: Split into sentences using existing sentence-per-line logic
2081    let sentence_lines =
2082        reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2083
2084    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
2085    // When line_length is 0 (unlimited), skip cascading — sentence splits only
2086    if options.line_length == 0 {
2087        return sentence_lines;
2088    }
2089
2090    let length_mode = options.length_mode;
2091    let mut result = Vec::new();
2092    for line in sentence_lines {
2093        if display_len(&line, length_mode) <= options.line_length {
2094            result.push(line);
2095        } else {
2096            result.extend(cascade_split_line(
2097                &line,
2098                options.line_length,
2099                &options.abbreviations,
2100                length_mode,
2101                options.attr_lists,
2102                options.myst_roles,
2103                options.defined_references.as_ref(),
2104            ));
2105        }
2106    }
2107
2108    // Step 3: Merge very short trailing lines back into the previous line.
2109    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
2110    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2111    let mut merged: Vec<String> = Vec::with_capacity(result.len());
2112    for line in result {
2113        if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2114            // Don't merge a line that is itself a standalone parenthetical group —
2115            // it was placed on its own line intentionally by split_at_parenthetical.
2116            if is_standalone_parenthetical(&line) {
2117                merged.push(line);
2118                continue;
2119            }
2120
2121            // Don't merge across sentence boundaries — sentence splits are intentional
2122            let prev_ends_at_sentence = {
2123                let trimmed = merged.last().unwrap().trim_end();
2124                trimmed
2125                    .chars()
2126                    .rev()
2127                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2128                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2129            };
2130
2131            if !prev_ends_at_sentence {
2132                let prev = merged.last_mut().unwrap();
2133                let combined = format!("{prev} {line}");
2134                // Only merge if the combined line fits within the limit
2135                if display_len(&combined, length_mode) <= options.line_length {
2136                    *prev = combined;
2137                    continue;
2138                }
2139            }
2140        }
2141        merged.push(line);
2142    }
2143    merged
2144}
2145
2146/// Find the last space in `line` that is safe to split at.
2147/// Safe spaces are those NOT inside rendered non-Text elements.
2148/// `element_spans` contains (start, end) byte ranges of non-Text elements in the line.
2149/// Find the last space in `line` that is not inside any element span.
2150/// Spans use exclusive bounds (pos > start && pos < end) because element
2151/// delimiters (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never
2152/// spaces, so only interior positions need protection.
2153fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2154    line.char_indices()
2155        .rev()
2156        .map(|(pos, _)| pos)
2157        .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2158}
2159
2160/// Reflow elements into lines that fit within the line length
2161fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2162    let mut lines = Vec::new();
2163    let mut current_line = String::new();
2164    let mut current_length = 0;
2165    // Track byte spans of non-Text elements in current_line for safe splitting
2166    let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2167    let length_mode = options.length_mode;
2168
2169    for (idx, element) in elements.iter().enumerate() {
2170        // Derive the display width from the already-formatted string rather than
2171        // formatting the element a second time just to measure it.
2172        let element_str = format!("{element}");
2173        let element_len = display_len(&element_str, length_mode);
2174
2175        // Determine adjacency from the original elements, not from current_line.
2176        // Elements are adjacent when there's no whitespace between them in the source:
2177        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
2178        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
2179        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
2180        let is_adjacent_to_prev = if idx > 0 {
2181            match (&elements[idx - 1], element) {
2182                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2183                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2184                _ => true,
2185            }
2186        } else {
2187            false
2188        };
2189
2190        // For text elements that might need breaking
2191        if let Element::Text(text) = element {
2192            // Check if original text had leading whitespace
2193            let has_leading_space = text.starts_with(char::is_whitespace);
2194            // If this is a text element, always process it word by word
2195            let words: Vec<&str> = text.split_whitespace().collect();
2196
2197            for (i, word) in words.iter().enumerate() {
2198                let word_len = display_len(word, length_mode);
2199                // Check if this "word" is just punctuation that should stay attached
2200                let is_trailing_punct = word
2201                    .chars()
2202                    .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2203
2204                // First word of text adjacent to preceding non-text element
2205                // must stay attached (e.g., shortcode followed by punctuation or text)
2206                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2207
2208                if is_first_adjacent {
2209                    // Attach directly without space, preventing line break
2210                    if current_length + word_len > options.line_length && current_length > 0 {
2211                        // Would exceed — break before the adjacent group
2212                        // Use element-aware space search to avoid splitting inside links/code/etc.
2213                        if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2214                            let before = current_line[..last_space].trim_end().to_string();
2215                            let after = current_line[last_space + 1..].to_string();
2216                            lines.push(before);
2217                            current_line = format!("{after}{word}");
2218                            current_length = display_len(&current_line, length_mode);
2219                            current_line_element_spans.clear();
2220                        } else {
2221                            current_line.push_str(word);
2222                            current_length += word_len;
2223                        }
2224                    } else {
2225                        current_line.push_str(word);
2226                        current_length += word_len;
2227                    }
2228                } else if current_length > 0
2229                    && current_length + 1 + word_len > options.line_length
2230                    && !is_trailing_punct
2231                {
2232                    // Start a new line (but never for trailing punctuation)
2233                    lines.push(current_line.trim().to_string());
2234                    current_line = word.to_string();
2235                    current_length = word_len;
2236                    current_line_element_spans.clear();
2237                } else {
2238                    // Add a space only where the source had whitespace at this position.
2239                    // For the first word of a text run (i == 0) that means the source had a
2240                    // leading space — and reaching this branch already implies the word is
2241                    // not adjacent to the previous element, so the space is real and must be
2242                    // kept even for punctuation. Suppressing it here would delete the space
2243                    // after an inline element, e.g. `` `code` } `` -> `` `code`} ``. The
2244                    // no-space (adjacent) case is handled above by `is_first_adjacent`.
2245                    // Within a text run (i > 0) trailing punctuation still attaches to the
2246                    // preceding word.
2247                    let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2248                    if add_space {
2249                        current_line.push(' ');
2250                        current_length += 1;
2251                    }
2252                    current_line.push_str(word);
2253                    current_length += word_len;
2254                }
2255            }
2256        } else if matches!(
2257            element,
2258            Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2259        ) && element_len > options.line_length
2260        {
2261            // Italic, bold, and strikethrough with content longer than line_length need word wrapping.
2262            // Split content word-by-word, attach the opening marker to the first word
2263            // and the closing marker to the last word.
2264            let (content, marker): (&str, &str) = match element {
2265                Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2266                Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2267                Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2268                _ => unreachable!(),
2269            };
2270
2271            let words: Vec<&str> = content.split_whitespace().collect();
2272            let n = words.len();
2273
2274            if n == 0 {
2275                // Empty span — treat as atomic
2276                let full = format!("{marker}{marker}");
2277                let full_len = display_len(&full, length_mode);
2278                if !is_adjacent_to_prev && current_length > 0 {
2279                    current_line.push(' ');
2280                    current_length += 1;
2281                }
2282                current_line.push_str(&full);
2283                current_length += full_len;
2284            } else {
2285                for (i, word) in words.iter().enumerate() {
2286                    let is_first = i == 0;
2287                    let is_last = i == n - 1;
2288                    let word_str: String = match (is_first, is_last) {
2289                        (true, true) => format!("{marker}{word}{marker}"),
2290                        (true, false) => format!("{marker}{word}"),
2291                        (false, true) => format!("{word}{marker}"),
2292                        (false, false) => word.to_string(),
2293                    };
2294                    let word_len = display_len(&word_str, length_mode);
2295
2296                    let needs_space = if is_first {
2297                        !is_adjacent_to_prev && current_length > 0
2298                    } else {
2299                        current_length > 0
2300                    };
2301
2302                    if needs_space && current_length + 1 + word_len > options.line_length {
2303                        lines.push(current_line.trim_end().to_string());
2304                        current_line = word_str;
2305                        current_length = word_len;
2306                        current_line_element_spans.clear();
2307                    } else {
2308                        if needs_space {
2309                            current_line.push(' ');
2310                            current_length += 1;
2311                        }
2312                        current_line.push_str(&word_str);
2313                        current_length += word_len;
2314                    }
2315                }
2316            }
2317        } else {
2318            // For non-text elements (code, links, references), treat as atomic units
2319            // These should never be broken across lines
2320
2321            if is_adjacent_to_prev {
2322                // Adjacent to preceding text — attach directly without space
2323                if current_length + element_len > options.line_length {
2324                    // Would exceed limit — break before the adjacent word group
2325                    // Use element-aware space search to avoid splitting inside links/code/etc.
2326                    if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2327                        let before = current_line[..last_space].trim_end().to_string();
2328                        let after = current_line[last_space + 1..].to_string();
2329                        lines.push(before);
2330                        current_line = format!("{after}{element_str}");
2331                        current_length = display_len(&current_line, length_mode);
2332                        current_line_element_spans.clear();
2333                        // Record the element span in the new current_line
2334                        let start = after.len();
2335                        current_line_element_spans.push((start, start + element_str.len()));
2336                    } else {
2337                        // No safe space to break at — accept the long line
2338                        let start = current_line.len();
2339                        current_line.push_str(&element_str);
2340                        current_length += element_len;
2341                        current_line_element_spans.push((start, current_line.len()));
2342                    }
2343                } else {
2344                    let start = current_line.len();
2345                    current_line.push_str(&element_str);
2346                    current_length += element_len;
2347                    current_line_element_spans.push((start, current_line.len()));
2348                }
2349            } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2350                // Not adjacent, would exceed — start new line
2351                lines.push(current_line.trim().to_string());
2352                current_line.clone_from(&element_str);
2353                current_length = element_len;
2354                current_line_element_spans.clear();
2355                current_line_element_spans.push((0, element_str.len()));
2356            } else {
2357                // Not adjacent, fits — add with space
2358                let ends_with_opener =
2359                    current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2360                if current_length > 0 && !ends_with_opener {
2361                    current_line.push(' ');
2362                    current_length += 1;
2363                }
2364                let start = current_line.len();
2365                current_line.push_str(&element_str);
2366                current_length += element_len;
2367                current_line_element_spans.push((start, current_line.len()));
2368            }
2369        }
2370    }
2371
2372    // Don't forget the last line
2373    if !current_line.is_empty() {
2374        lines.push(current_line.trim_end().to_string());
2375    }
2376
2377    lines
2378}
2379
2380/// Reflow markdown content preserving structure
2381pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2382    let lines: Vec<&str> = content.lines().collect();
2383    let mut result = Vec::new();
2384    let mut i = 0;
2385
2386    while i < lines.len() {
2387        let line = lines[i];
2388        let trimmed = line.trim();
2389
2390        // Preserve empty lines
2391        if trimmed.is_empty() {
2392            result.push(String::new());
2393            i += 1;
2394            continue;
2395        }
2396
2397        // Preserve headings as-is
2398        if trimmed.starts_with('#') {
2399            result.push(line.to_string());
2400            i += 1;
2401            continue;
2402        }
2403
2404        // Preserve Quarto/Pandoc div markers (:::) as-is
2405        if trimmed.starts_with(":::") {
2406            result.push(line.to_string());
2407            i += 1;
2408            continue;
2409        }
2410
2411        // Preserve fenced code blocks
2412        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2413            result.push(line.to_string());
2414            i += 1;
2415            // Copy lines until closing fence
2416            while i < lines.len() {
2417                result.push(lines[i].to_string());
2418                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2419                    i += 1;
2420                    break;
2421                }
2422                i += 1;
2423            }
2424            continue;
2425        }
2426
2427        // Preserve indented code blocks (4+ columns accounting for tab expansion)
2428        if calculate_indentation_width_default(line) >= 4 {
2429            // Collect all consecutive indented lines
2430            result.push(line.to_string());
2431            i += 1;
2432            while i < lines.len() {
2433                let next_line = lines[i];
2434                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
2435                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2436                    result.push(next_line.to_string());
2437                    i += 1;
2438                } else {
2439                    break;
2440                }
2441            }
2442            continue;
2443        }
2444
2445        // Preserve block quotes (but reflow their content)
2446        if trimmed.starts_with('>') {
2447            // find() returns byte position which is correct for str slicing
2448            // The unwrap is safe because we already verified trimmed starts with '>'
2449            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2450            let quote_prefix = line[0..=gt_pos].to_string();
2451            let quote_content = &line[quote_prefix.len()..].trim_start();
2452
2453            let reflowed = reflow_line(quote_content, options);
2454            for reflowed_line in &reflowed {
2455                result.push(format!("{quote_prefix} {reflowed_line}"));
2456            }
2457            i += 1;
2458            continue;
2459        }
2460
2461        // Preserve horizontal rules first (before checking for lists)
2462        if is_horizontal_rule(trimmed) {
2463            result.push(line.to_string());
2464            i += 1;
2465            continue;
2466        }
2467
2468        // Preserve lists (but not horizontal rules)
2469        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2470            // Find the list marker and preserve indentation
2471            let indent = line.len() - line.trim_start().len();
2472            let indent_str = " ".repeat(indent);
2473
2474            // For numbered lists, find the period and the space after it
2475            // For bullet lists, find the marker and the space after it
2476            let mut marker_end = indent;
2477            let mut content_start = indent;
2478
2479            if trimmed.chars().next().is_some_and(char::is_numeric) {
2480                // Numbered list: find the period
2481                if let Some(period_pos) = line[indent..].find('.') {
2482                    marker_end = indent + period_pos + 1; // Include the period
2483                    content_start = marker_end;
2484                    // Skip any spaces after the period to find content start
2485                    // Use byte-based check since content_start is a byte index
2486                    // This is safe because space is ASCII (single byte)
2487                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2488                        content_start += 1;
2489                    }
2490                }
2491            } else {
2492                // Bullet list: marker is single character
2493                marker_end = indent + 1; // Just the marker character
2494                content_start = marker_end;
2495                // Skip any spaces after the marker
2496                // Use byte-based check since content_start is a byte index
2497                // This is safe because space is ASCII (single byte)
2498                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2499                    content_start += 1;
2500                }
2501            }
2502
2503            // Minimum indent for continuation lines (based on list marker, before checkbox)
2504            let min_continuation_indent = content_start;
2505
2506            // Detect checkbox/task list markers: [ ], [x], [X]
2507            // GFM task lists work with both unordered and ordered lists
2508            let rest = &line[content_start..];
2509            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2510                marker_end = content_start + 3; // Include the checkbox `[ ]`
2511                content_start += 4; // Skip past `[ ] `
2512            }
2513
2514            let marker = &line[indent..marker_end];
2515
2516            // Collect all content for this list item (including continuation lines)
2517            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
2518            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2519            i += 1;
2520
2521            // Collect continuation lines (indented lines that are part of this list item)
2522            // Use the base marker indent (not checkbox-extended) for collection,
2523            // since users may indent continuations to the bullet level, not the checkbox level
2524            while i < lines.len() {
2525                let next_line = lines[i];
2526                let next_trimmed = next_line.trim();
2527
2528                // Stop if we hit an empty line or another list item or special block
2529                if is_block_boundary(next_trimmed) {
2530                    break;
2531                }
2532
2533                // Check if this line is indented (continuation of list item)
2534                let next_indent = next_line.len() - next_line.trim_start().len();
2535                if next_indent >= min_continuation_indent {
2536                    // This is a continuation line - add its content
2537                    // Preserve hard breaks while trimming excessive whitespace
2538                    let trimmed_start = next_line.trim_start();
2539                    list_content.push(trim_preserving_hard_break(trimmed_start));
2540                    i += 1;
2541                } else {
2542                    // Not indented enough, not part of this list item
2543                    break;
2544                }
2545            }
2546
2547            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
2548            // Hard breaks should prevent joining with the next line
2549            let combined_content = if options.preserve_breaks {
2550                list_content[0].clone()
2551            } else {
2552                // Check if any lines have hard breaks - if so, preserve the structure
2553                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2554                if has_hard_breaks {
2555                    // Don't join lines with hard breaks - keep them separate with newlines
2556                    list_content.join("\n")
2557                } else {
2558                    // No hard breaks, safe to join with spaces
2559                    list_content.join(" ")
2560                }
2561            };
2562
2563            // Calculate the proper indentation for continuation lines
2564            let trimmed_marker = marker;
2565            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2566                // Cap the relative indent (past the nesting level) to max_indent,
2567                // then add back the nesting indent so nested items stay correct
2568                indent + (content_start - indent).min(max_indent)
2569            } else {
2570                content_start
2571            };
2572
2573            // Adjust line length to account for list marker and space
2574            let prefix_length = indent + trimmed_marker.len() + 1;
2575
2576            // Create adjusted options with reduced line length
2577            let adjusted_options = ReflowOptions {
2578                line_length: options.line_length.saturating_sub(prefix_length),
2579                ..options.clone()
2580            };
2581
2582            let reflowed = reflow_line(&combined_content, &adjusted_options);
2583            for (j, reflowed_line) in reflowed.iter().enumerate() {
2584                if j == 0 {
2585                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2586                } else {
2587                    // Continuation lines aligned with text after marker
2588                    let continuation_indent = " ".repeat(continuation_spaces);
2589                    result.push(format!("{continuation_indent}{reflowed_line}"));
2590                }
2591            }
2592            continue;
2593        }
2594
2595        // Preserve tables
2596        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2597            result.push(line.to_string());
2598            i += 1;
2599            continue;
2600        }
2601
2602        // Preserve reference definitions
2603        if trimmed.starts_with('[') && line.contains("]:") {
2604            result.push(line.to_string());
2605            i += 1;
2606            continue;
2607        }
2608
2609        // Preserve definition list items (extended markdown)
2610        if is_definition_list_item(trimmed) {
2611            result.push(line.to_string());
2612            i += 1;
2613            continue;
2614        }
2615
2616        // Check if this is a single line that doesn't need processing
2617        let mut is_single_line_paragraph = true;
2618        if i + 1 < lines.len() {
2619            let next_trimmed = lines[i + 1].trim();
2620            // Check if next line continues this paragraph
2621            if !is_block_boundary(next_trimmed) {
2622                is_single_line_paragraph = false;
2623            }
2624        }
2625
2626        // If it's a single line that fits, just add it as-is
2627        if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2628            result.push(line.to_string());
2629            i += 1;
2630            continue;
2631        }
2632
2633        // For regular paragraphs, collect consecutive lines
2634        let mut paragraph_parts = Vec::new();
2635        let mut current_part = vec![line];
2636        i += 1;
2637
2638        // If preserve_breaks is true, treat each line separately
2639        if options.preserve_breaks {
2640            // Don't collect consecutive lines - just reflow this single line
2641            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2642                Some("\\")
2643            } else if line.ends_with("  ") {
2644                Some("  ")
2645            } else {
2646                None
2647            };
2648            let reflowed = reflow_line(line, options);
2649
2650            // Preserve hard breaks (two trailing spaces or backslash)
2651            if let Some(break_marker) = hard_break_type {
2652                if !reflowed.is_empty() {
2653                    let mut reflowed_with_break = reflowed;
2654                    let last_idx = reflowed_with_break.len() - 1;
2655                    if !has_hard_break(&reflowed_with_break[last_idx]) {
2656                        reflowed_with_break[last_idx].push_str(break_marker);
2657                    }
2658                    result.extend(reflowed_with_break);
2659                }
2660            } else {
2661                result.extend(reflowed);
2662            }
2663        } else {
2664            // Original behavior: collect consecutive lines into a paragraph
2665            while i < lines.len() {
2666                let prev_line = if !current_part.is_empty() {
2667                    current_part.last().unwrap()
2668                } else {
2669                    ""
2670                };
2671                let next_line = lines[i];
2672                let next_trimmed = next_line.trim();
2673
2674                // Stop at empty lines or special blocks
2675                if is_block_boundary(next_trimmed) {
2676                    break;
2677                }
2678
2679                // Check if previous line ends with hard break (two spaces or backslash)
2680                // or is a complete sentence in sentence_per_line mode
2681                let prev_trimmed = prev_line.trim();
2682                let abbreviations = get_abbreviations(&options.abbreviations);
2683                let ends_with_sentence = (prev_trimmed.ends_with('.')
2684                    || prev_trimmed.ends_with('!')
2685                    || prev_trimmed.ends_with('?')
2686                    || prev_trimmed.ends_with(".*")
2687                    || prev_trimmed.ends_with("!*")
2688                    || prev_trimmed.ends_with("?*")
2689                    || prev_trimmed.ends_with("._")
2690                    || prev_trimmed.ends_with("!_")
2691                    || prev_trimmed.ends_with("?_")
2692                    // Quote-terminated sentences (straight and curly quotes)
2693                    || prev_trimmed.ends_with(".\"")
2694                    || prev_trimmed.ends_with("!\"")
2695                    || prev_trimmed.ends_with("?\"")
2696                    || prev_trimmed.ends_with(".'")
2697                    || prev_trimmed.ends_with("!'")
2698                    || prev_trimmed.ends_with("?'")
2699                    || prev_trimmed.ends_with(".\u{201D}")
2700                    || prev_trimmed.ends_with("!\u{201D}")
2701                    || prev_trimmed.ends_with("?\u{201D}")
2702                    || prev_trimmed.ends_with(".\u{2019}")
2703                    || prev_trimmed.ends_with("!\u{2019}")
2704                    || prev_trimmed.ends_with("?\u{2019}"))
2705                    && !text_ends_with_abbreviation(
2706                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2707                        &abbreviations,
2708                    );
2709
2710                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2711                    // Start a new part after hard break or complete sentence
2712                    paragraph_parts.push(current_part.join(" "));
2713                    current_part = vec![next_line];
2714                } else {
2715                    current_part.push(next_line);
2716                }
2717                i += 1;
2718            }
2719
2720            // Add the last part
2721            if !current_part.is_empty() {
2722                if current_part.len() == 1 {
2723                    // Single line, don't add trailing space
2724                    paragraph_parts.push(current_part[0].to_string());
2725                } else {
2726                    paragraph_parts.push(current_part.join(" "));
2727                }
2728            }
2729
2730            // Reflow each part separately, preserving hard breaks
2731            for (j, part) in paragraph_parts.iter().enumerate() {
2732                let reflowed = reflow_line(part, options);
2733                result.extend(reflowed);
2734
2735                // Preserve hard break by ensuring last line of part ends with hard break marker
2736                // Use two spaces as the default hard break format for reflows
2737                // But don't add hard breaks in sentence_per_line mode - lines are already separate
2738                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2739                    let last_idx = result.len() - 1;
2740                    if !has_hard_break(&result[last_idx]) {
2741                        result[last_idx].push_str("  ");
2742                    }
2743                }
2744            }
2745        }
2746    }
2747
2748    // Preserve trailing newline if the original content had one
2749    let result_text = result.join("\n");
2750    if content.ends_with('\n') && !result_text.ends_with('\n') {
2751        format!("{result_text}\n")
2752    } else {
2753        result_text
2754    }
2755}
2756
2757/// Information about a reflowed paragraph
2758#[derive(Debug, Clone)]
2759pub struct ParagraphReflow {
2760    /// Starting byte offset of the paragraph in the original content
2761    pub start_byte: usize,
2762    /// Ending byte offset of the paragraph in the original content
2763    pub end_byte: usize,
2764    /// The reflowed text for this paragraph
2765    pub reflowed_text: String,
2766}
2767
2768/// A collected blockquote line used for style-preserving reflow.
2769///
2770/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
2771/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
2772/// rather than constructing the struct directly.
2773#[derive(Debug, Clone)]
2774pub struct BlockquoteLineData {
2775    /// Trimmed content without the `> ` prefix.
2776    pub(crate) content: String,
2777    /// Whether this line carries an explicit blockquote marker.
2778    pub(crate) is_explicit: bool,
2779    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
2780    pub(crate) prefix: Option<String>,
2781}
2782
2783impl BlockquoteLineData {
2784    /// Create an explicit (marker-bearing) blockquote line.
2785    pub fn explicit(content: String, prefix: String) -> Self {
2786        Self {
2787            content,
2788            is_explicit: true,
2789            prefix: Some(prefix),
2790        }
2791    }
2792
2793    /// Create a lazy continuation line (no blockquote marker).
2794    pub fn lazy(content: String) -> Self {
2795        Self {
2796            content,
2797            is_explicit: false,
2798            prefix: None,
2799        }
2800    }
2801}
2802
2803/// Style for blockquote continuation lines after reflow.
2804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2805pub enum BlockquoteContinuationStyle {
2806    Explicit,
2807    Lazy,
2808}
2809
2810/// Determine the continuation style for a blockquote paragraph from its collected lines.
2811///
2812/// The first line is always explicit (it carries the marker), so only continuation
2813/// lines (index 1+) are counted. Ties resolve to `Explicit`.
2814///
2815/// When the slice has only one element (no continuation lines to inspect), both
2816/// counts are zero and the tie-breaking rule returns `Explicit`.
2817pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2818    let mut explicit_count = 0usize;
2819    let mut lazy_count = 0usize;
2820
2821    for line in lines.iter().skip(1) {
2822        if line.is_explicit {
2823            explicit_count += 1;
2824        } else {
2825            lazy_count += 1;
2826        }
2827    }
2828
2829    if explicit_count > 0 && lazy_count == 0 {
2830        BlockquoteContinuationStyle::Explicit
2831    } else if lazy_count > 0 && explicit_count == 0 {
2832        BlockquoteContinuationStyle::Lazy
2833    } else if explicit_count >= lazy_count {
2834        BlockquoteContinuationStyle::Explicit
2835    } else {
2836        BlockquoteContinuationStyle::Lazy
2837    }
2838}
2839
2840/// Determine the dominant blockquote prefix for a paragraph.
2841///
2842/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
2843/// first appearance. Falls back to `fallback` when no explicit lines are present.
2844pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2845    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2846
2847    for (idx, line) in lines.iter().enumerate() {
2848        let Some(prefix) = line.prefix.as_ref() else {
2849            continue;
2850        };
2851        counts
2852            .entry(prefix.clone())
2853            .and_modify(|entry| entry.0 += 1)
2854            .or_insert((1, idx));
2855    }
2856
2857    counts
2858        .into_iter()
2859        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2860            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2861        })
2862        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2863}
2864
2865/// Whether a reflowed blockquote content line must carry an explicit prefix.
2866///
2867/// Lines that would start a new block structure (headings, fences, lists, etc.)
2868/// cannot safely use lazy continuation syntax.
2869pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2870    let trimmed = content_line.trim_start();
2871    trimmed.starts_with('>')
2872        || trimmed.starts_with('#')
2873        || trimmed.starts_with("```")
2874        || trimmed.starts_with("~~~")
2875        || is_unordered_list_marker(trimmed)
2876        || is_numbered_list_item(trimmed)
2877        || is_horizontal_rule(trimmed)
2878        || is_definition_list_item(trimmed)
2879        || (trimmed.starts_with('[') && trimmed.contains("]:"))
2880        || trimmed.starts_with(":::")
2881        || (trimmed.starts_with('<')
2882            && !trimmed.starts_with("<http")
2883            && !trimmed.starts_with("<https")
2884            && !trimmed.starts_with("<mailto:"))
2885}
2886
2887/// Reflow blockquote content lines and apply continuation style.
2888///
2889/// Segments separated by hard breaks are reflowed independently. The output lines
2890/// receive blockquote prefixes according to `continuation_style`: the first line and
2891/// any line that would start a new block structure always get an explicit prefix;
2892/// other lines follow the detected style.
2893///
2894/// Returns the styled, reflowed lines (without a trailing newline).
2895pub fn reflow_blockquote_content(
2896    lines: &[BlockquoteLineData],
2897    explicit_prefix: &str,
2898    continuation_style: BlockquoteContinuationStyle,
2899    options: &ReflowOptions,
2900) -> Vec<String> {
2901    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2902    let segments = split_into_segments_strs(&content_strs);
2903    let mut reflowed_content_lines: Vec<String> = Vec::new();
2904
2905    for segment in segments {
2906        let hard_break_type = segment.last().and_then(|&line| {
2907            let line = line.strip_suffix('\r').unwrap_or(line);
2908            if line.ends_with('\\') {
2909                Some("\\")
2910            } else if line.ends_with("  ") {
2911                Some("  ")
2912            } else {
2913                None
2914            }
2915        });
2916
2917        let pieces: Vec<&str> = segment
2918            .iter()
2919            .map(|&line| {
2920                if let Some(l) = line.strip_suffix('\\') {
2921                    l.trim_end()
2922                } else if let Some(l) = line.strip_suffix("  ") {
2923                    l.trim_end()
2924                } else {
2925                    line.trim_end()
2926                }
2927            })
2928            .collect();
2929
2930        let segment_text = pieces.join(" ");
2931        let segment_text = segment_text.trim();
2932        if segment_text.is_empty() {
2933            continue;
2934        }
2935
2936        let mut reflowed = reflow_line(segment_text, options);
2937        if let Some(break_marker) = hard_break_type
2938            && !reflowed.is_empty()
2939        {
2940            let last_idx = reflowed.len() - 1;
2941            if !has_hard_break(&reflowed[last_idx]) {
2942                reflowed[last_idx].push_str(break_marker);
2943            }
2944        }
2945        reflowed_content_lines.extend(reflowed);
2946    }
2947
2948    let mut styled_lines: Vec<String> = Vec::new();
2949    for (idx, line) in reflowed_content_lines.iter().enumerate() {
2950        let force_explicit = idx == 0
2951            || continuation_style == BlockquoteContinuationStyle::Explicit
2952            || should_force_explicit_blockquote_line(line);
2953        if force_explicit {
2954            styled_lines.push(format!("{explicit_prefix}{line}"));
2955        } else {
2956            styled_lines.push(line.clone());
2957        }
2958    }
2959
2960    styled_lines
2961}
2962
2963fn is_blockquote_content_boundary(content: &str) -> bool {
2964    let trimmed = content.trim();
2965    trimmed.is_empty()
2966        || is_block_boundary(trimmed)
2967        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2968        || trimmed.starts_with(":::")
2969        || crate::utils::is_template_directive_only(content)
2970        || is_standalone_attr_list(content)
2971        || is_snippet_block_delimiter(content)
2972}
2973
2974fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2975    let mut segments = Vec::new();
2976    let mut current = Vec::new();
2977
2978    for &line in lines {
2979        current.push(line);
2980        if has_hard_break(line) {
2981            segments.push(current);
2982            current = Vec::new();
2983        }
2984    }
2985
2986    if !current.is_empty() {
2987        segments.push(current);
2988    }
2989
2990    segments
2991}
2992
2993fn reflow_blockquote_paragraph_at_line(
2994    content: &str,
2995    lines: &[&str],
2996    target_idx: usize,
2997    options: &ReflowOptions,
2998) -> Option<ParagraphReflow> {
2999    let mut anchor_idx = target_idx;
3000    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3001        parsed.nesting_level
3002    } else {
3003        let mut found = None;
3004        let mut idx = target_idx;
3005        loop {
3006            if lines[idx].trim().is_empty() {
3007                break;
3008            }
3009            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3010                found = Some((idx, parsed.nesting_level));
3011                break;
3012            }
3013            if idx == 0 {
3014                break;
3015            }
3016            idx -= 1;
3017        }
3018        let (idx, level) = found?;
3019        anchor_idx = idx;
3020        level
3021    };
3022
3023    // Expand backward to capture prior quote content at the same nesting level.
3024    let mut para_start = anchor_idx;
3025    while para_start > 0 {
3026        let prev_idx = para_start - 1;
3027        let prev_line = lines[prev_idx];
3028
3029        if prev_line.trim().is_empty() {
3030            break;
3031        }
3032
3033        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3034            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3035                break;
3036            }
3037            para_start = prev_idx;
3038            continue;
3039        }
3040
3041        let prev_lazy = prev_line.trim_start();
3042        if is_blockquote_content_boundary(prev_lazy) {
3043            break;
3044        }
3045        para_start = prev_idx;
3046    }
3047
3048    // Lazy continuation cannot precede the first explicit marker.
3049    while para_start < lines.len() {
3050        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3051            para_start += 1;
3052            continue;
3053        };
3054        target_level = parsed.nesting_level;
3055        break;
3056    }
3057
3058    if para_start >= lines.len() || para_start > target_idx {
3059        return None;
3060    }
3061
3062    // Collect explicit lines at target level and lazy continuation lines.
3063    // Each entry is (original_line_idx, BlockquoteLineData).
3064    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3065    let mut idx = para_start;
3066    while idx < lines.len() {
3067        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3068            break;
3069        }
3070
3071        let line = lines[idx];
3072        if line.trim().is_empty() {
3073            break;
3074        }
3075
3076        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3077            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3078                break;
3079            }
3080            collected.push((
3081                idx,
3082                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3083            ));
3084            idx += 1;
3085            continue;
3086        }
3087
3088        let lazy_content = line.trim_start();
3089        if is_blockquote_content_boundary(lazy_content) {
3090            break;
3091        }
3092
3093        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3094        idx += 1;
3095    }
3096
3097    if collected.is_empty() {
3098        return None;
3099    }
3100
3101    let para_end = collected[collected.len() - 1].0;
3102    if target_idx < para_start || target_idx > para_end {
3103        return None;
3104    }
3105
3106    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3107
3108    let fallback_prefix = line_data
3109        .iter()
3110        .find_map(|d| d.prefix.clone())
3111        .unwrap_or_else(|| "> ".to_string());
3112    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3113    let continuation_style = blockquote_continuation_style(&line_data);
3114
3115    let adjusted_line_length = options
3116        .line_length
3117        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3118        .max(1);
3119
3120    let adjusted_options = ReflowOptions {
3121        line_length: adjusted_line_length,
3122        ..options.clone()
3123    };
3124
3125    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3126
3127    if styled_lines.is_empty() {
3128        return None;
3129    }
3130
3131    // Calculate byte offsets.
3132    let mut start_byte = 0;
3133    for line in lines.iter().take(para_start) {
3134        start_byte += line.len() + 1;
3135    }
3136
3137    let mut end_byte = start_byte;
3138    for line in lines.iter().take(para_end + 1).skip(para_start) {
3139        end_byte += line.len() + 1;
3140    }
3141
3142    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3143    if !includes_trailing_newline {
3144        end_byte -= 1;
3145    }
3146
3147    let reflowed_joined = styled_lines.join("\n");
3148    let reflowed_text = if includes_trailing_newline {
3149        if reflowed_joined.ends_with('\n') {
3150            reflowed_joined
3151        } else {
3152            format!("{reflowed_joined}\n")
3153        }
3154    } else if reflowed_joined.ends_with('\n') {
3155        reflowed_joined.trim_end_matches('\n').to_string()
3156    } else {
3157        reflowed_joined
3158    };
3159
3160    Some(ParagraphReflow {
3161        start_byte,
3162        end_byte,
3163        reflowed_text,
3164    })
3165}
3166
3167/// Reflow a single paragraph at the specified line number
3168///
3169/// This function finds the paragraph containing the given line number,
3170/// reflows it according to the specified line length, and returns
3171/// information about the paragraph location and its reflowed text.
3172///
3173/// # Arguments
3174///
3175/// * `content` - The full document content
3176/// * `line_number` - The 1-based line number within the paragraph to reflow
3177/// * `line_length` - The target line length for reflowing
3178///
3179/// # Returns
3180///
3181/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
3182/// or `None` if the line number is out of bounds or the content at that
3183/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
3184pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3185    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3186}
3187
3188/// Reflow a paragraph at the given line with a specific length mode.
3189pub fn reflow_paragraph_at_line_with_mode(
3190    content: &str,
3191    line_number: usize,
3192    line_length: usize,
3193    length_mode: ReflowLengthMode,
3194) -> Option<ParagraphReflow> {
3195    let options = ReflowOptions {
3196        line_length,
3197        length_mode,
3198        ..Default::default()
3199    };
3200    reflow_paragraph_at_line_with_options(content, line_number, &options)
3201}
3202
3203/// Reflow a paragraph at the given line using the provided options.
3204///
3205/// This is the canonical implementation used by both the rule's fix mode and the
3206/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
3207/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
3208///
3209/// # Returns
3210///
3211/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
3212/// if the line is out of bounds or sits inside a non-reflow-able construct.
3213pub fn reflow_paragraph_at_line_with_options(
3214    content: &str,
3215    line_number: usize,
3216    options: &ReflowOptions,
3217) -> Option<ParagraphReflow> {
3218    if line_number == 0 {
3219        return None;
3220    }
3221
3222    let lines: Vec<&str> = content.lines().collect();
3223
3224    // Check if line number is valid (1-based)
3225    if line_number > lines.len() {
3226        return None;
3227    }
3228
3229    let target_idx = line_number - 1; // Convert to 0-based
3230    let target_line = lines[target_idx];
3231    let trimmed = target_line.trim();
3232
3233    // Handle blockquote paragraphs (including lazy continuation lines) with
3234    // style-preserving output.
3235    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3236        return Some(blockquote_reflow);
3237    }
3238
3239    // Don't reflow special blocks
3240    if is_paragraph_boundary(trimmed, target_line) {
3241        return None;
3242    }
3243
3244    // Find paragraph start - scan backward until blank line or special block
3245    let mut para_start = target_idx;
3246    while para_start > 0 {
3247        let prev_idx = para_start - 1;
3248        let prev_line = lines[prev_idx];
3249        let prev_trimmed = prev_line.trim();
3250
3251        // Stop at blank line or special blocks
3252        if is_paragraph_boundary(prev_trimmed, prev_line) {
3253            break;
3254        }
3255
3256        para_start = prev_idx;
3257    }
3258
3259    // Find paragraph end - scan forward until blank line or special block
3260    let mut para_end = target_idx;
3261    while para_end + 1 < lines.len() {
3262        let next_idx = para_end + 1;
3263        let next_line = lines[next_idx];
3264        let next_trimmed = next_line.trim();
3265
3266        // Stop at blank line or special blocks
3267        if is_paragraph_boundary(next_trimmed, next_line) {
3268            break;
3269        }
3270
3271        para_end = next_idx;
3272    }
3273
3274    // Extract paragraph lines
3275    let paragraph_lines = &lines[para_start..=para_end];
3276
3277    // Calculate byte offsets
3278    let mut start_byte = 0;
3279    for line in lines.iter().take(para_start) {
3280        start_byte += line.len() + 1; // +1 for newline
3281    }
3282
3283    let mut end_byte = start_byte;
3284    for line in paragraph_lines {
3285        end_byte += line.len() + 1; // +1 for newline
3286    }
3287
3288    // Track whether the byte range includes a trailing newline
3289    // (it doesn't if this is the last line and the file doesn't end with newline)
3290    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3291
3292    // Adjust end_byte if the last line doesn't have a newline
3293    if !includes_trailing_newline {
3294        end_byte -= 1;
3295    }
3296
3297    // Join paragraph lines and reflow
3298    let paragraph_text = paragraph_lines.join("\n");
3299
3300    // Reflow the paragraph using reflow_markdown to handle it properly
3301    let reflowed = reflow_markdown(&paragraph_text, options);
3302
3303    // Ensure reflowed text matches whether the byte range includes a trailing newline
3304    // This is critical: if the range includes a newline, the replacement must too,
3305    // otherwise the next line will get appended to the reflowed paragraph
3306    let reflowed_text = if includes_trailing_newline {
3307        // Range includes newline - ensure reflowed text has one
3308        if reflowed.ends_with('\n') {
3309            reflowed
3310        } else {
3311            format!("{reflowed}\n")
3312        }
3313    } else {
3314        // Range doesn't include newline - ensure reflowed text doesn't have one
3315        if reflowed.ends_with('\n') {
3316            reflowed.trim_end_matches('\n').to_string()
3317        } else {
3318            reflowed
3319        }
3320    };
3321
3322    Some(ParagraphReflow {
3323        start_byte,
3324        end_byte,
3325        reflowed_text,
3326    })
3327}
3328
3329#[cfg(test)]
3330mod tests {
3331    use super::*;
3332
3333    /// Unit test for private helper function text_ends_with_abbreviation()
3334    ///
3335    /// This test stays inline because it tests a private function.
3336    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
3337    #[test]
3338    fn test_helper_function_text_ends_with_abbreviation() {
3339        // Test the helper function directly
3340        let abbreviations = get_abbreviations(&None);
3341
3342        // True cases - built-in abbreviations (titles and i.e./e.g.)
3343        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3344        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3345        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3346        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3347        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3348        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3349        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3350        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3351
3352        // False cases - NOT in built-in list (etc doesn't always have period)
3353        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3354        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3355        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3356        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3357        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3358        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
3359        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
3360        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
3361        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
3362        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
3363    }
3364
3365    #[test]
3366    fn test_is_unordered_list_marker() {
3367        // Valid unordered list markers
3368        assert!(is_unordered_list_marker("- item"));
3369        assert!(is_unordered_list_marker("* item"));
3370        assert!(is_unordered_list_marker("+ item"));
3371        assert!(is_unordered_list_marker("-")); // lone marker
3372        assert!(is_unordered_list_marker("*"));
3373        assert!(is_unordered_list_marker("+"));
3374
3375        // Not list markers
3376        assert!(!is_unordered_list_marker("---")); // horizontal rule
3377        assert!(!is_unordered_list_marker("***")); // horizontal rule
3378        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
3379        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
3380        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
3381        assert!(!is_unordered_list_marker("-word")); // no space after marker
3382        assert!(!is_unordered_list_marker("")); // empty
3383        assert!(!is_unordered_list_marker("text")); // plain text
3384        assert!(!is_unordered_list_marker("# heading")); // heading
3385    }
3386
3387    #[test]
3388    fn test_is_block_boundary() {
3389        // Block boundaries
3390        assert!(is_block_boundary("")); // empty line
3391        assert!(is_block_boundary("# Heading")); // ATX heading
3392        assert!(is_block_boundary("## Level 2")); // ATX heading
3393        assert!(is_block_boundary("```rust")); // code fence
3394        assert!(is_block_boundary("~~~")); // tilde code fence
3395        assert!(is_block_boundary("> quote")); // blockquote
3396        assert!(is_block_boundary("| cell |")); // table
3397        assert!(is_block_boundary("[link]: http://example.com")); // reference def
3398        assert!(is_block_boundary("---")); // horizontal rule
3399        assert!(is_block_boundary("***")); // horizontal rule
3400        assert!(is_block_boundary("- item")); // unordered list
3401        assert!(is_block_boundary("* item")); // unordered list
3402        assert!(is_block_boundary("+ item")); // unordered list
3403        assert!(is_block_boundary("1. item")); // ordered list
3404        assert!(is_block_boundary("10. item")); // ordered list
3405        assert!(is_block_boundary(": definition")); // definition list
3406        assert!(is_block_boundary(":::")); // div marker
3407        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
3408
3409        // NOT block boundaries (paragraph continuation)
3410        assert!(!is_block_boundary("regular text"));
3411        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
3412        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
3413        assert!(!is_block_boundary("some words here"));
3414    }
3415
3416    #[test]
3417    fn test_definition_list_boundary_in_single_line_paragraph() {
3418        // Verifies that a definition list item after a single-line paragraph
3419        // is treated as a block boundary, not merged into the paragraph
3420        let options = ReflowOptions {
3421            line_length: 80,
3422            ..Default::default()
3423        };
3424        let input = "Term\n: Definition of the term";
3425        let result = reflow_markdown(input, &options);
3426        // The definition list marker should remain on its own line
3427        assert!(
3428            result.contains(": Definition"),
3429            "Definition list item should not be merged into previous line. Got: {result:?}"
3430        );
3431        let lines: Vec<&str> = result.lines().collect();
3432        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3433        assert_eq!(lines[0], "Term");
3434        assert_eq!(lines[1], ": Definition of the term");
3435    }
3436
3437    #[test]
3438    fn test_is_paragraph_boundary() {
3439        // Core block boundary checks are inherited
3440        assert!(is_paragraph_boundary("# Heading", "# Heading"));
3441        assert!(is_paragraph_boundary("- item", "- item"));
3442        assert!(is_paragraph_boundary(":::", ":::"));
3443        assert!(is_paragraph_boundary(": definition", ": definition"));
3444
3445        // Indented code blocks (≥4 spaces or tab)
3446        assert!(is_paragraph_boundary("code", "    code"));
3447        assert!(is_paragraph_boundary("code", "\tcode"));
3448
3449        // Table rows via is_potential_table_row
3450        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3451        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
3452
3453        // Not paragraph boundaries
3454        assert!(!is_paragraph_boundary("regular text", "regular text"));
3455        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
3456    }
3457
3458    #[test]
3459    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3460        // Verifies that div markers (:::) are treated as paragraph boundaries
3461        // in reflow_paragraph_at_line, preventing reflow across div boundaries
3462        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3463        // Line 3 is the div marker — should not be reflowed
3464        let result = reflow_paragraph_at_line(content, 3, 80);
3465        assert!(result.is_none(), "Div marker line should not be reflowed");
3466    }
3467}