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