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