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