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