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        b'0'..=b'9' => {
1923            let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
1924            digits <= 9
1925                && bytes.len() > digits
1926                && (bytes[digits] == b'.' || bytes[digits] == b')')
1927                && marker_then_boundary(digits + 1)
1928        }
1929        // Footnote/link-reference definition: `[label]:` anchored at line
1930        // start, meaning the label's own closing bracket is immediately
1931        // followed by a colon ("[ref]: url", "[^1]: note" - but not
1932        // "[a](b) [ref]:", whose first bracket is an inline link). rumdl's
1933        // parser recognizes definitions even on paragraph-continuation lines,
1934        // so hoisting one to line start reclassifies it (and can resolve
1935        // dangling references elsewhere in the document).
1936        b'[' => {
1937            let mut escaped = false;
1938            let mut label_close = None;
1939            for (i, &b) in bytes.iter().enumerate().skip(1) {
1940                if escaped {
1941                    escaped = false;
1942                } else if b == b'\\' {
1943                    escaped = true;
1944                } else if b == b']' {
1945                    label_close = Some(i);
1946                    break;
1947                }
1948            }
1949            label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
1950        }
1951        // Block-level HTML tag per rumdl's parser (shared predicate, so the
1952        // guard cannot drift from what lint_context classifies as a block).
1953        b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
1954        _ => false,
1955    }
1956}
1957
1958/// Merge any reflowed continuation line that would open a block construct back
1959/// into the previous line. This is the safety net behind the per-break-site
1960/// guards: no matter which emitter produced the lines, a wrapped continuation
1961/// must never turn prose into a list item, heading, blockquote, code fence, or
1962/// horizontal rule. The first line keeps its position - it replaces the
1963/// paragraph's original start, where the source already established the
1964/// context. The merged line may exceed the configured width; a long line is
1965/// the correct failure direction, corrupted structure is not.
1966fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
1967    let mut merged: Vec<String> = Vec::with_capacity(lines.len());
1968    for line in lines {
1969        match merged.last_mut() {
1970            Some(prev) if starts_block_construct(&line) => {
1971                prev.push(' ');
1972                prev.push_str(line.trim_start());
1973            }
1974            _ => merged.push(line),
1975        }
1976    }
1977    merged
1978}
1979
1980/// Reflow elements for sentence-per-line mode
1981fn reflow_elements_sentence_per_line(
1982    elements: &[Element],
1983    custom_abbreviations: &Option<Vec<String>>,
1984    require_sentence_capital: bool,
1985) -> Vec<String> {
1986    let abbreviations = get_abbreviations(custom_abbreviations);
1987    let mut lines = Vec::new();
1988    let mut current_line = String::new();
1989
1990    for (idx, element) in elements.iter().enumerate() {
1991        // For text elements, split into sentences
1992        if let Element::Text(text) = element {
1993            // Simply append text - it already has correct spacing from tokenization
1994            let combined = format!("{current_line}{text}");
1995            // Use the pre-computed abbreviations set to avoid redundant computation
1996            let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1997
1998            if sentences.len() > 1 {
1999                // We found sentence boundaries
2000                for (i, sentence) in sentences.iter().enumerate() {
2001                    if i == 0 {
2002                        // First sentence might continue from previous elements
2003                        // But check if it ends with an abbreviation
2004                        let trimmed = sentence.trim();
2005
2006                        if text_ends_with_abbreviation(trimmed, &abbreviations) {
2007                            // Don't emit yet - this sentence ends with abbreviation, continue accumulating
2008                            current_line.clone_from(sentence);
2009                        } else {
2010                            // Normal case - emit the first sentence
2011                            lines.push(sentence.clone());
2012                            current_line.clear();
2013                        }
2014                    } else if i == sentences.len() - 1 {
2015                        // Last sentence: check if it's complete or incomplete
2016                        let trimmed = sentence.trim();
2017                        let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2018
2019                        if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2020                            // Complete sentence - emit it immediately
2021                            lines.push(sentence.clone());
2022                            current_line.clear();
2023                        } else {
2024                            // Incomplete sentence - save for next iteration
2025                            current_line.clone_from(sentence);
2026                        }
2027                    } else {
2028                        // Complete sentences in the middle
2029                        lines.push(sentence.clone());
2030                    }
2031                }
2032            } else {
2033                // Single sentence - check if it's complete
2034                let trimmed = combined.trim();
2035
2036                // If the combined result is only whitespace, don't accumulate it.
2037                // This prevents leading spaces on subsequent elements when lines
2038                // are joined with spaces during reflow iteration.
2039                if trimmed.is_empty() {
2040                    continue;
2041                }
2042
2043                let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2044
2045                if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
2046                    // Complete single sentence - emit it (trimming only
2047                    // breakable whitespace so edge NBSPs survive)
2048                    lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2049                    current_line.clear();
2050                } else {
2051                    // Incomplete sentence - continue accumulating
2052                    current_line = combined;
2053                }
2054            }
2055        } else if let Element::Italic { content, underscore } = element {
2056            // Handle italic elements - may contain multiple sentences that need continuation
2057            let marker = if *underscore { "_" } else { "*" };
2058            handle_emphasis_sentence_split(
2059                content,
2060                marker,
2061                &abbreviations,
2062                require_sentence_capital,
2063                &mut current_line,
2064                &mut lines,
2065            );
2066        } else if let Element::Bold { content, underscore } = element {
2067            // Handle bold elements - may contain multiple sentences that need continuation
2068            let marker = if *underscore { "__" } else { "**" };
2069            handle_emphasis_sentence_split(
2070                content,
2071                marker,
2072                &abbreviations,
2073                require_sentence_capital,
2074                &mut current_line,
2075                &mut lines,
2076            );
2077        } else if let Element::Strikethrough { content, double } = element {
2078            // Handle strikethrough elements - may contain multiple sentences that need continuation
2079            handle_emphasis_sentence_split(
2080                content,
2081                if *double { "~~" } else { "~" },
2082                &abbreviations,
2083                require_sentence_capital,
2084                &mut current_line,
2085                &mut lines,
2086            );
2087        } else {
2088            // Non-text, non-emphasis elements (Code, Links, etc.)
2089            let element_str = format!("{element}");
2090            // Check if this element is adjacent to the preceding text (no
2091            // breakable space between; a non-breaking space keeps the pair
2092            // attached and must not have an ASCII space appended after it)
2093            let is_adjacent = if idx > 0 {
2094                match &elements[idx - 1] {
2095                    Element::Text(t) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2096                    _ => true,
2097                }
2098            } else {
2099                false
2100            };
2101
2102            // Add space before element if needed, but not for adjacent elements
2103            if !is_adjacent && should_insert_space_before_join(&current_line) {
2104                current_line.push(' ');
2105            }
2106            current_line.push_str(&element_str);
2107        }
2108    }
2109
2110    // Add any remaining content
2111    if !current_line.is_empty() {
2112        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2113    }
2114    lines
2115}
2116
2117/// Handle splitting emphasis content at sentence boundaries while preserving markers
2118fn handle_emphasis_sentence_split(
2119    content: &str,
2120    marker: &str,
2121    abbreviations: &HashSet<String>,
2122    require_sentence_capital: bool,
2123    current_line: &mut String,
2124    lines: &mut Vec<String>,
2125) {
2126    // Split the emphasis content into sentences
2127    let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
2128
2129    if sentences.len() <= 1 {
2130        // Single sentence or no boundaries - treat as atomic
2131        if should_insert_space_before_join(current_line) {
2132            current_line.push(' ');
2133        }
2134        current_line.push_str(marker);
2135        current_line.push_str(content);
2136        current_line.push_str(marker);
2137
2138        // Check if the emphasis content ends with sentence punctuation - if so, emit
2139        let trimmed = content.trim();
2140        let ends_with_punct = ends_with_sentence_punct(trimmed);
2141        if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2142            lines.push(current_line.clone());
2143            current_line.clear();
2144        }
2145    } else {
2146        // Multiple sentences - each gets its own emphasis markers
2147        for (i, sentence) in sentences.iter().enumerate() {
2148            let trimmed = sentence.trim();
2149            if trimmed.is_empty() {
2150                continue;
2151            }
2152
2153            if i == 0 {
2154                // First sentence: combine with current_line and emit
2155                if should_insert_space_before_join(current_line) {
2156                    current_line.push(' ');
2157                }
2158                current_line.push_str(marker);
2159                current_line.push_str(trimmed);
2160                current_line.push_str(marker);
2161
2162                // Check if this is a complete sentence
2163                let ends_with_punct = ends_with_sentence_punct(trimmed);
2164                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2165                    lines.push(current_line.clone());
2166                    current_line.clear();
2167                }
2168            } else if i == sentences.len() - 1 {
2169                // Last sentence: check if complete
2170                let ends_with_punct = ends_with_sentence_punct(trimmed);
2171
2172                let mut line = String::new();
2173                line.push_str(marker);
2174                line.push_str(trimmed);
2175                line.push_str(marker);
2176
2177                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
2178                    lines.push(line);
2179                } else {
2180                    // Incomplete - keep in current_line for potential continuation
2181                    *current_line = line;
2182                }
2183            } else {
2184                // Middle sentences: emit with markers
2185                let mut line = String::new();
2186                line.push_str(marker);
2187                line.push_str(trimmed);
2188                line.push_str(marker);
2189                lines.push(line);
2190            }
2191        }
2192    }
2193}
2194
2195/// English break-words used for semantic line break splitting.
2196/// These are conjunctions and relative pronouns where a line break
2197/// reads naturally.
2198const BREAK_WORDS: &[&str] = &[
2199    "and",
2200    "or",
2201    "but",
2202    "nor",
2203    "yet",
2204    "so",
2205    "for",
2206    "which",
2207    "that",
2208    "because",
2209    "when",
2210    "if",
2211    "while",
2212    "where",
2213    "although",
2214    "though",
2215    "unless",
2216    "since",
2217    "after",
2218    "before",
2219    "until",
2220    "as",
2221    "once",
2222    "whether",
2223    "however",
2224    "therefore",
2225    "moreover",
2226    "furthermore",
2227    "nevertheless",
2228    "whereas",
2229];
2230
2231/// Check if a character is clause punctuation for semantic line breaks
2232fn is_clause_punctuation(c: char) -> bool {
2233    matches!(c, ',' | ';' | ':' | '\u{2014}') // comma, semicolon, colon, em dash
2234}
2235
2236/// Whether a clause-punctuation char at `chars[i]` is a legitimate break point.
2237///
2238/// A real clause boundary is followed by whitespace (or ends the text): `,;:`
2239/// with no following space sit *inside* a token (`16:9`, `key:value`, a MyST role
2240/// like `{cite:p}`) and must not be split there. The em dash (`—`) is exempt:
2241/// it commonly joins words with no surrounding spaces and breaking after it reads
2242/// naturally.
2243fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2244    if chars[i] == '\u{2014}' {
2245        return true;
2246    }
2247    match chars.get(i + 1) {
2248        None => true,
2249        Some(next) => next.is_whitespace(),
2250    }
2251}
2252
2253/// Find the closing `)` that balances the `(` at the start of `slice`.
2254///
2255/// `offset` is the byte position of the `(` in the original full-line string;
2256/// it is used to translate local byte positions into global positions for
2257/// element-span lookups.  Parens inside markdown element spans are skipped so
2258/// that, e.g., the closing `)` of an inline link does not prematurely end the
2259/// scan.  The char's *start* byte (not byte-after) is used for the span check
2260/// so that closing element delimiters — which sit exactly at the span's
2261/// exclusive-end boundary — are correctly excluded.
2262///
2263/// Returns `(end_local, inner)` where `end_local` is the byte offset within
2264/// `slice` just past the closing `)`, and `inner` is the content between the
2265/// outermost `(` and `)`.
2266fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
2267    debug_assert!(slice.starts_with('('));
2268    let mut depth: i32 = 0;
2269    for (local_byte, c) in slice.char_indices() {
2270        let global_byte = offset + local_byte;
2271        // When depth > 0, skip parens that belong to a markdown element.
2272        // Use the char's start byte so that a closing element delimiter
2273        // (whose byte_after equals the span's exclusive end) is treated as
2274        // inside the element rather than outside it.
2275        if depth > 0 && is_inside_element(global_byte, element_spans) {
2276            continue;
2277        }
2278        match c {
2279            '(' => depth += 1,
2280            ')' => {
2281                depth -= 1;
2282                if depth == 0 {
2283                    let end = local_byte + 1;
2284                    let inner = &slice[1..local_byte];
2285                    return Some((end, inner));
2286                }
2287            }
2288            _ => {}
2289        }
2290    }
2291    None
2292}
2293
2294/// Split a line at a parenthetical boundary for semantic line breaks.
2295///
2296/// Two strategies are tried in order:
2297///
2298/// 1. **Leading parenthetical** — if the line begins with `(`, isolate the
2299///    entire balanced group on this line and start the rest on the next.
2300///    This handles lines produced by a prior split that placed a `(` at the
2301///    very beginning.
2302///
2303/// 2. **Mid-line parenthetical** — find the rightmost balanced `(…)` whose
2304///    content spans multiple words and whose preceding text fits within
2305///    `[min_first_len, line_length]`.  Split just before the `(` so the
2306///    parenthetical begins the following line.
2307///
2308/// Parentheses that fall inside markdown element spans (links, code, etc.)
2309/// are ignored in both strategies.
2310fn split_at_parenthetical(
2311    text: &str,
2312    line_length: usize,
2313    element_spans: &[(usize, usize)],
2314    length_mode: ReflowLengthMode,
2315) -> Option<(String, String)> {
2316    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2317
2318    // Strategy 1: text starts with '(' — isolate the parenthetical as its own line.
2319    if text.starts_with('(')
2320        && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2321        && inner.contains(' ')
2322    {
2323        // If closing quotes or clause punctuation immediately follow the closing
2324        // ')', attach them to the parenthetical so the continuation line does
2325        // not start with a bare quote, comma, or semicolon.
2326        let tail = &text[end_local..];
2327        let attached_len = tail
2328            .char_indices()
2329            .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
2330            .last()
2331            .map_or(0, |(idx, c)| idx + c.len_utf8());
2332        let first_end = end_local + attached_len;
2333        let rest_start = first_end;
2334        let first = &text[..first_end];
2335        let first_len = display_len(first, length_mode);
2336        // No MIN_SPLIT_RATIO check: a parenthetical unit is always a valid
2337        // semantic line regardless of its length.
2338        if first_len <= line_length {
2339            let rest = text[rest_start..].trim_start();
2340            if !rest.is_empty() {
2341                return Some((first.to_string(), rest.to_string()));
2342            }
2343        }
2344    }
2345
2346    // Strategy 2: find the rightmost multi-word '(' whose preceding text fits.
2347    let mut best_open_byte: Option<usize> = None;
2348    let mut pos = 0usize;
2349    while pos < text.len() {
2350        // '(' is ASCII so a single-byte comparison is safe in UTF-8.
2351        if text.as_bytes()[pos] != b'(' {
2352            let c = text[pos..].chars().next().unwrap();
2353            pos += c.len_utf8();
2354            continue;
2355        }
2356        // Skip '(' that are part of a markdown element (use start byte).
2357        if is_inside_element(pos, element_spans) {
2358            pos += 1;
2359            continue;
2360        }
2361        if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2362            let first = text[..pos].trim_end();
2363            let first_len = display_len(first, length_mode);
2364            if !first.is_empty()
2365                && first_len >= min_first_len
2366                && first_len <= line_length
2367                && inner.contains(' ')
2368                && best_open_byte.is_none_or(|prev| pos > prev)
2369            {
2370                best_open_byte = Some(pos);
2371            }
2372            pos += end_local;
2373        } else {
2374            pos += 1;
2375        }
2376    }
2377
2378    let open_byte = best_open_byte?;
2379    let first = text[..open_byte].trim_end().to_string();
2380    let rest = text[open_byte..].to_string();
2381    if first.is_empty() || rest.trim().is_empty() {
2382        return None;
2383    }
2384    Some((first, rest))
2385}
2386
2387/// Compute element spans for a flat text representation of elements.
2388/// Returns Vec of (start, end) byte offsets for non-Text elements,
2389/// so we can check that a split position doesn't fall inside them.
2390fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
2391    let mut spans = Vec::new();
2392    let mut offset = 0;
2393    for element in elements {
2394        let len = element.display_len(ReflowLengthMode::Bytes);
2395        if !matches!(element, Element::Text(_)) {
2396            spans.push((offset, offset + len));
2397        }
2398        offset += len;
2399    }
2400    spans
2401}
2402
2403/// Check if a byte position falls inside any non-Text element span
2404fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
2405    spans.iter().any(|(start, end)| pos > *start && pos < *end)
2406}
2407
2408/// Minimum fraction of line_length that the first part of a split must occupy.
2409/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
2410const MIN_SPLIT_RATIO: f64 = 0.3;
2411
2412/// Split a line at the latest clause punctuation that keeps the first part
2413/// within `line_length`. Returns None if no valid split point exists or if
2414/// the split would create an unreasonably short first line.
2415fn split_at_clause_punctuation(
2416    text: &str,
2417    line_length: usize,
2418    element_spans: &[(usize, usize)],
2419    length_mode: ReflowLengthMode,
2420) -> Option<(String, String)> {
2421    let chars: Vec<char> = text.chars().collect();
2422    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2423
2424    // Find the char index where accumulated display width exceeds line_length
2425    let mut width_acc = 0;
2426    let mut search_end_char = 0;
2427    for (idx, &c) in chars.iter().enumerate() {
2428        let c_width = display_len(&c.to_string(), length_mode);
2429        if width_acc + c_width > line_length {
2430            break;
2431        }
2432        width_acc += c_width;
2433        search_end_char = idx + 1;
2434    }
2435
2436    // Scan backwards tracking parenthesis depth to skip clause punctuation
2437    // inside plain-text parenthetical groups.  Scanning right-to-left means
2438    // ')' opens a depth level and '(' closes it.  Parens that belong to a
2439    // markdown element are excluded using the char's start byte (not byte-after)
2440    // so that closing element delimiters at the span boundary are correctly
2441    // treated as part of the element.
2442    let mut paren_depth: i32 = 0;
2443    let mut best_pos = None;
2444    for i in (0..search_end_char).rev() {
2445        // Start byte of char i (for paren element check)
2446        let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2447        // Byte just after char i (for clause punctuation element check — existing convention)
2448        let byte_after: usize = byte_start + chars[i].len_utf8();
2449
2450        if !is_inside_element(byte_start, element_spans) {
2451            match chars[i] {
2452                ')' => paren_depth += 1,
2453                '(' => paren_depth = paren_depth.saturating_sub(1),
2454                _ => {}
2455            }
2456        }
2457
2458        if paren_depth == 0
2459            && is_clause_punctuation(chars[i])
2460            && clause_break_allowed_after(&chars, i)
2461            && !is_inside_element(byte_after, element_spans)
2462        {
2463            best_pos = Some(i);
2464            break;
2465        }
2466    }
2467
2468    let pos = best_pos?;
2469
2470    // Reject splits that create very short first lines
2471    let first: String = chars[..=pos].iter().collect();
2472    let first_display_len = display_len(&first, length_mode);
2473    if first_display_len < min_first_len {
2474        return None;
2475    }
2476
2477    // Split after the punctuation character
2478    let rest: String = chars[pos + 1..].iter().collect();
2479    let rest = rest.trim_start().to_string();
2480
2481    if rest.is_empty() {
2482        return None;
2483    }
2484
2485    Some((first, rest))
2486}
2487
2488/// Compute plain-text paren-depth at each byte offset in `text`.
2489///
2490/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
2491/// nesting depth at byte `i` — counting only `(` and `)` that fall
2492/// outside markdown element spans.  This lets callers quickly check
2493/// whether a byte position lies inside a plain-text parenthetical group.
2494fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
2495    let mut map = vec![0i32; text.len()];
2496    let mut depth = 0i32;
2497    for (byte, c) in text.char_indices() {
2498        if !is_inside_element(byte, element_spans) {
2499            match c {
2500                '(' => depth += 1,
2501                ')' => depth = depth.saturating_sub(1),
2502                _ => {}
2503            }
2504        }
2505        // Fill the depth value for every byte of this (possibly multi-byte) char.
2506        let end = (byte + c.len_utf8()).min(map.len());
2507        for slot in &mut map[byte..end] {
2508            *slot = depth;
2509        }
2510    }
2511    map
2512}
2513
2514/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
2515/// group — i.e. it starts with `(`, ends with `)` (possibly followed by
2516/// clause punctuation), has balanced parens throughout, and the inner content
2517/// contains at least one space (matching the ≥2-word threshold used by
2518/// `split_at_parenthetical` when deciding to split).
2519///
2520/// Used to prevent the short-line merge step from collapsing intentional
2521/// parenthetical splits back into the previous line.
2522fn is_standalone_parenthetical(line: &str) -> bool {
2523    let trimmed = line.trim();
2524    if !trimmed.starts_with('(') {
2525        return false;
2526    }
2527    // Strip optional trailing clause punctuation to find the real end.
2528    let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
2529    if !core.ends_with(')') {
2530        return false;
2531    }
2532    // Inner content must span multiple words (same threshold as split_at_parenthetical).
2533    let inner = &core[1..core.len() - 1];
2534    if !inner.contains(' ') {
2535        return false;
2536    }
2537    // Verify the parens are balanced (depth returns to 0 at the last ')').
2538    let mut depth = 0i32;
2539    for c in core.chars() {
2540        match c {
2541            '(' => depth += 1,
2542            ')' => depth -= 1,
2543            _ => {}
2544        }
2545        if depth < 0 {
2546            return false;
2547        }
2548    }
2549    depth == 0
2550}
2551
2552/// Split a line before the latest break-word that keeps the first part
2553/// within `line_length`. Returns None if no valid split point exists or if
2554/// the split would create an unreasonably short first line.
2555fn split_at_break_word(
2556    text: &str,
2557    line_length: usize,
2558    element_spans: &[(usize, usize)],
2559    length_mode: ReflowLengthMode,
2560) -> Option<(String, String)> {
2561    let lower = text.to_lowercase();
2562    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2563    let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
2564
2565    // Build a paren-depth map so we can skip break-words inside plain-text
2566    // parenthetical groups (matching the protection added to split_at_clause_punctuation).
2567    let depth_map = paren_depth_map(text, element_spans);
2568
2569    for &word in BREAK_WORDS {
2570        let mut search_start = 0;
2571        while let Some(pos) = lower[search_start..].find(word) {
2572            let abs_pos = search_start + pos;
2573
2574            // Verify it's a word boundary: preceded by space, followed by space
2575            let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2576            let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2577
2578            if preceded_by_space && followed_by_space {
2579                // The break goes BEFORE the word, so first part ends at abs_pos - 1
2580                let first_part = text[..abs_pos].trim_end();
2581                let first_part_len = display_len(first_part, length_mode);
2582
2583                // Skip break-words inside plain-text parenthetical groups.
2584                let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2585
2586                if first_part_len >= min_first_len
2587                    && first_part_len <= line_length
2588                    && !is_inside_element(abs_pos, element_spans)
2589                    && !inside_paren
2590                {
2591                    // Prefer the latest valid split point
2592                    if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2593                        best_split = Some((abs_pos, word.len()));
2594                    }
2595                }
2596            }
2597
2598            search_start = abs_pos + word.len();
2599        }
2600    }
2601
2602    let (byte_start, _word_len) = best_split?;
2603
2604    let first = text[..byte_start].trim_end().to_string();
2605    let rest = text[byte_start..].to_string();
2606
2607    if first.is_empty() || rest.trim().is_empty() {
2608        return None;
2609    }
2610
2611    Some((first, rest))
2612}
2613
2614/// Cascade-split a line that exceeds line_length.
2615/// Tries parenthetical boundaries, then clause punctuation, then break-words,
2616/// then word wrap.
2617///
2618/// This is iterative rather than recursive so a single very long line (tens of
2619/// thousands of words) cannot overflow the stack. Each accepted split shrinks
2620/// the remaining text by a non-empty prefix, so the loop always makes progress.
2621/// The whole line is parsed into markdown elements once up front; every
2622/// remaining suffix reuses those element spans (re-based to the suffix offset)
2623/// instead of re-parsing, which keeps repeated element parsing out of the loop.
2624fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2625    let line_length = options.line_length;
2626    let length_mode = options.length_mode;
2627    let attr_lists = options.attr_lists;
2628    let myst_roles = options.myst_roles;
2629    let defined_references = options.defined_references.as_ref();
2630    if line_length == 0 || display_len(text, length_mode) <= line_length {
2631        return vec![text.to_string()];
2632    }
2633
2634    let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2635    let element_spans = compute_element_spans(&elements);
2636
2637    // Element spans of the remaining suffix `text[start..]`, re-based so their
2638    // offsets are relative to the suffix. Split points never fall inside an
2639    // element, so every span lies wholly before or wholly at/after `start`.
2640    let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2641        if start == 0 {
2642            return element_spans.clone();
2643        }
2644        element_spans
2645            .iter()
2646            .filter(|&&(_, end)| end > start)
2647            .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2648            .collect()
2649    };
2650
2651    let mut result = Vec::new();
2652    let mut start = 0usize;
2653
2654    loop {
2655        let remaining = &text[start..];
2656        if display_len(remaining, length_mode) <= line_length {
2657            result.push(remaining.to_string());
2658            return result;
2659        }
2660
2661        let spans = rebased_spans(start);
2662
2663        // `rest` is always a suffix of `remaining` (the splitters only trim its
2664        // leading whitespace), so `remaining.len() - rest.len()` is the number of
2665        // bytes consumed, and the new absolute offset is `start + consumed`.
2666        let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2667            .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2668            .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2669
2670        if let Some((first, rest)) = split {
2671            let consumed = remaining.len().saturating_sub(rest.len());
2672            // Defensive: a zero-length advance would loop forever. Splitters only
2673            // return a non-empty `first`, so this never triggers, but guard anyway.
2674            if consumed == 0 {
2675                break;
2676            }
2677            result.push(first);
2678            start += consumed;
2679            continue;
2680        }
2681
2682        // No semantic split point: word-wrap the remaining suffix and finish.
2683        break;
2684    }
2685
2686    // Fallback: word wrap the still-oversized suffix using reflow_elements.
2687    let mut fallback_options = options.clone();
2688    fallback_options.break_on_sentences = false;
2689    fallback_options.preserve_breaks = false;
2690    fallback_options.sentence_per_line = false;
2691    fallback_options.semantic_line_breaks = false;
2692    fallback_options.require_sentence_capital = true;
2693    fallback_options.max_list_continuation_indent = None;
2694    fallback_options.defined_references = None;
2695    let remaining = &text[start..];
2696    let tail_elements = if start == 0 {
2697        elements
2698    } else {
2699        parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2700    };
2701    result.extend(reflow_elements(&tail_elements, &fallback_options));
2702    result
2703}
2704
2705/// Reflow elements using semantic line breaks strategy:
2706/// 1. Split at sentence boundaries (always)
2707/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
2708fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2709    // Step 1: Split into sentences using existing sentence-per-line logic
2710    let sentence_lines =
2711        reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2712
2713    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
2714    // When line_length is 0 (unlimited), skip cascading — sentence splits only
2715    if options.line_length == 0 {
2716        return sentence_lines;
2717    }
2718
2719    let length_mode = options.length_mode;
2720    let mut result = Vec::new();
2721    for line in sentence_lines {
2722        if display_len(&line, length_mode) <= options.line_length {
2723            result.push(line);
2724        } else {
2725            result.extend(cascade_split_line(&line, options));
2726        }
2727    }
2728
2729    // Step 3: Merge very short trailing lines back into the previous line.
2730    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
2731    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2732    let mut merged: Vec<String> = Vec::with_capacity(result.len());
2733    for line in result {
2734        if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2735            // Don't merge a line that is itself a standalone parenthetical group —
2736            // it was placed on its own line intentionally by split_at_parenthetical.
2737            if is_standalone_parenthetical(&line) {
2738                merged.push(line);
2739                continue;
2740            }
2741
2742            // Don't merge across sentence boundaries — sentence splits are intentional
2743            let prev_ends_at_sentence = {
2744                let trimmed = merged.last().unwrap().trim_end();
2745                trimmed
2746                    .chars()
2747                    .rev()
2748                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2749                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2750            };
2751
2752            if !prev_ends_at_sentence {
2753                let prev = merged.last_mut().unwrap();
2754                let combined = format!("{prev} {line}");
2755                // Only merge if the combined line fits within the limit
2756                if display_len(&combined, length_mode) <= options.line_length {
2757                    *prev = combined;
2758                    continue;
2759                }
2760            }
2761        }
2762        merged.push(line);
2763    }
2764    merged
2765}
2766
2767/// Find the last space in `line` that is safe to split at.
2768/// Safe spaces are those NOT inside rendered non-Text elements and whose
2769/// suffix would not open a block construct when placed at line start.
2770/// `element_spans` contains (start, end) byte ranges of non-Text elements in
2771/// the line. Spans use exclusive bounds (pos > start && pos < end) because
2772/// element delimiters (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never
2773/// spaces, so only interior positions need protection. The scan keeps looking
2774/// left past construct-leading suffixes (e.g. a trailing `- `), so a usable
2775/// earlier break point is found instead of forcing an overlong line.
2776fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2777    line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
2778        line.as_bytes()[pos] == b' '
2779            && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e)
2780            && !starts_block_construct(&line[pos + 1..])
2781    })
2782}
2783
2784/// Break `current_line` one word earlier so `attach` never starts a wrapped
2785/// line: everything before the line's last safe space is emitted as a
2786/// finished line, and the carried word plus `separator` plus `attach` becomes
2787/// the new current line. Element spans are cleared; the returned byte length
2788/// of the carried word lets callers re-record a span for `attach`. Returns
2789/// `None` (line untouched) when the line has no safe break point.
2790fn break_before_attached(
2791    lines: &mut Vec<String>,
2792    current_line: &mut String,
2793    current_length: &mut usize,
2794    element_spans: &mut Vec<(usize, usize)>,
2795    attach: &str,
2796    separator: &str,
2797    length_mode: ReflowLengthMode,
2798) -> Option<usize> {
2799    let last_space = rfind_safe_space(current_line, element_spans)?;
2800    let before = current_line[..last_space]
2801        .trim_end_matches(is_breakable_whitespace)
2802        .to_string();
2803    let after = current_line[last_space + 1..].to_string();
2804    lines.push(before);
2805    let carried = after.len();
2806    *current_line = format!("{after}{separator}{attach}");
2807    *current_length = display_len(current_line, length_mode);
2808    element_spans.clear();
2809    Some(carried)
2810}
2811
2812/// Reflow elements into lines that fit within the line length
2813fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2814    let mut lines = Vec::new();
2815    let mut current_line = String::new();
2816    let mut current_length = 0;
2817    // Track byte spans of non-Text elements in current_line for safe splitting
2818    let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2819    let length_mode = options.length_mode;
2820
2821    for (idx, element) in elements.iter().enumerate() {
2822        let element_len = element.display_len(length_mode);
2823
2824        // Determine adjacency from the original elements, not from current_line.
2825        // Elements are adjacent when there's no breakable whitespace between them
2826        // in the source (a non-breaking space stays inside the neighboring token,
2827        // so the pair must also stay attached):
2828        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
2829        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
2830        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
2831        // - Code("`x`") → Text("\u{00A0}:") = adjacent (only a non-breaking space between)
2832        let is_adjacent_to_prev = if idx > 0 {
2833            match (&elements[idx - 1], element) {
2834                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
2835                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
2836                _ => true,
2837            }
2838        } else {
2839            false
2840        };
2841
2842        // For text elements that might need breaking
2843        if let Element::Text(text) = element {
2844            // Check if original text had leading breakable whitespace
2845            let has_leading_space = text.starts_with(is_breakable_whitespace);
2846            // If this is a text element, always process it word by word
2847            let words: Vec<&str> = split_breakable_words(text).collect();
2848
2849            for (i, word) in words.iter().enumerate() {
2850                let word_len = display_len(word, length_mode);
2851                // A token that is only punctuation (optionally led by a
2852                // non-breaking space, e.g. French "\u{00A0}:") must never be
2853                // hoisted to the start of a line. Tokens are never empty
2854                // (`split_breakable_words` filters), so `all` cannot be
2855                // vacuously true.
2856                let is_trailing_punct = word.chars().all(|c| {
2857                    matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
2858                });
2859
2860                // First word of text adjacent to preceding non-text element
2861                // must stay attached (e.g., shortcode followed by punctuation or text)
2862                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2863
2864                if is_first_adjacent {
2865                    // Attach directly without space, preventing line break
2866                    if current_length + word_len > options.line_length
2867                        && current_length > 0
2868                        && break_before_attached(
2869                            &mut lines,
2870                            &mut current_line,
2871                            &mut current_length,
2872                            &mut current_line_element_spans,
2873                            word,
2874                            "",
2875                            length_mode,
2876                        )
2877                        .is_some()
2878                    {
2879                        // Would exceed — broke before the adjacent group at the
2880                        // last safe space (element-aware, so links/code stay
2881                        // intact); with no safe break point the group is
2882                        // attached and the long line accepted.
2883                    } else {
2884                        current_line.push_str(word);
2885                        current_length += word_len;
2886                    }
2887                } else if current_length > 0 && current_length + 1 + word_len > options.line_length {
2888                    if is_trailing_punct {
2889                        // The overflowing token is bare punctuation, which must
2890                        // not start a line. Break one word earlier so the mark
2891                        // travels with the word it follows ("… mot :"), keeping
2892                        // the source space (French double punctuation requires
2893                        // it); with no safe earlier break point, accept the
2894                        // overlong line rather than rewrite content.
2895                        if break_before_attached(
2896                            &mut lines,
2897                            &mut current_line,
2898                            &mut current_length,
2899                            &mut current_line_element_spans,
2900                            word,
2901                            " ",
2902                            length_mode,
2903                        )
2904                        .is_none()
2905                        {
2906                            current_line.push(' ');
2907                            current_line.push_str(word);
2908                            current_length += 1 + word_len;
2909                        }
2910                    } else if !starts_block_construct(word) {
2911                        // Start a new line
2912                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2913                        current_line = word.to_string();
2914                        current_length = word_len;
2915                        current_line_element_spans.clear();
2916                    } else if break_before_attached(
2917                        &mut lines,
2918                        &mut current_line,
2919                        &mut current_length,
2920                        &mut current_line_element_spans,
2921                        word,
2922                        " ",
2923                        length_mode,
2924                    )
2925                    .is_some()
2926                    {
2927                        // The overflowing word would open a block construct at line
2928                        // start. Broke one word earlier instead so the marker stays
2929                        // mid-line: "... and then" + "- clause" becomes "... and" +
2930                        // "then - clause".
2931                    } else {
2932                        // No safe earlier break point — keep the marker attached and
2933                        // accept the long line rather than corrupt the structure.
2934                        if i > 0 || has_leading_space {
2935                            current_line.push(' ');
2936                            current_length += 1;
2937                        }
2938                        current_line.push_str(word);
2939                        current_length += word_len;
2940                    }
2941                } else {
2942                    // Add a space wherever the source had breakable whitespace at
2943                    // this position. For the first word of a text run (i == 0)
2944                    // that means the run had a leading space — and reaching this
2945                    // branch already implies the word is not adjacent to the
2946                    // previous element, so the space is real. Later words
2947                    // (i > 0) always had whitespace before them: that is what
2948                    // separated them during tokenization. This holds for bare
2949                    // punctuation too ("ligne : la" keeps its French
2950                    // orthographic space): reflow moves line breaks, it does not
2951                    // rewrite characters. The no-space (adjacent) case is
2952                    // handled above by `is_first_adjacent`.
2953                    let add_space = current_length > 0 && (i > 0 || has_leading_space);
2954                    if add_space {
2955                        current_line.push(' ');
2956                        current_length += 1;
2957                    }
2958                    current_line.push_str(word);
2959                    current_length += word_len;
2960                }
2961            }
2962        } else {
2963            let span_info = match element {
2964                Element::Italic { content, underscore } => {
2965                    Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
2966                }
2967                Element::Bold { content, underscore } => {
2968                    Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
2969                }
2970                Element::Strikethrough { content, double } => {
2971                    Some((content.as_str(), if *double { "~~" } else { "~" }, false))
2972                }
2973                Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
2974                _ => None,
2975            };
2976
2977            // A span that alone exceeds the line budget is broken even when
2978            // spans are atomic, since keeping it whole would leave a line that can
2979            // never fit. `breakable_units` decides where that is safe.
2980            let breakable: Option<Vec<&str>> = match span_info {
2981                Some((content, _, is_code)) => {
2982                    if is_code {
2983                        (!options.atomic_spans && code_span_wraps_losslessly(content))
2984                            .then(|| split_breakable_words(content).collect())
2985                    } else {
2986                        (!options.atomic_spans || element_len > options.line_length)
2987                            .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
2988                            .flatten()
2989                    }
2990                }
2991                None => None,
2992            };
2993
2994            if let Some(words) = breakable {
2995                let (_, marker, is_code) = span_info.expect("breakable implies a span");
2996                let n = words.len();
2997                if n == 0 {
2998                    // Empty span — treat as atomic
2999                    let full = format!("{marker}{marker}");
3000                    let full_len = display_len(&full, length_mode);
3001                    if !is_adjacent_to_prev && current_length > 0 {
3002                        current_line.push(' ');
3003                        current_length += 1;
3004                    }
3005                    current_line.push_str(&full);
3006                    current_length += full_len;
3007                } else {
3008                    for (i, word) in words.iter().enumerate() {
3009                        let is_first = i == 0;
3010                        let is_last = i == n - 1;
3011
3012                        let space_start = if is_first && is_code && word.starts_with('`') {
3013                            " "
3014                        } else {
3015                            ""
3016                        };
3017                        let space_end = if is_last && is_code && word.ends_with('`') {
3018                            " "
3019                        } else {
3020                            ""
3021                        };
3022
3023                        let word_str: String = match (is_first, is_last) {
3024                            (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3025                            (true, false) => format!("{marker}{space_start}{word}"),
3026                            (false, true) => format!("{word}{space_end}{marker}"),
3027                            (false, false) => word.to_string(),
3028                        };
3029                        let word_len = display_len(&word_str, length_mode);
3030
3031                        let needs_space = if is_first {
3032                            !is_adjacent_to_prev && current_length > 0
3033                        } else {
3034                            current_length > 0
3035                        };
3036
3037                        if needs_space
3038                            && current_length + 1 + word_len > options.line_length
3039                            && !starts_block_construct(&word_str)
3040                        {
3041                            lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3042                            current_line = word_str;
3043                            current_length = word_len;
3044                            current_line_element_spans.clear();
3045                        } else {
3046                            if needs_space {
3047                                current_line.push(' ');
3048                                current_length += 1;
3049                            }
3050                            current_line.push_str(&word_str);
3051                            current_length += word_len;
3052                        }
3053                    }
3054                }
3055            } else {
3056                // For non-text elements (code, links, references), treat as atomic units
3057                // These should never be broken across lines
3058                let element_str = format!("{element}");
3059
3060                if is_adjacent_to_prev {
3061                    // Adjacent to preceding text — attach directly without space
3062                    if current_length + element_len > options.line_length
3063                        && let Some(carried) = break_before_attached(
3064                            &mut lines,
3065                            &mut current_line,
3066                            &mut current_length,
3067                            &mut current_line_element_spans,
3068                            &element_str,
3069                            "",
3070                            length_mode,
3071                        )
3072                    {
3073                        // Would exceed limit — broke before the adjacent word group
3074                        // at the last safe space (element-aware, so links/code stay
3075                        // intact). Record the element span in the new current_line.
3076                        current_line_element_spans.push((carried, carried + element_str.len()));
3077                    } else {
3078                        let start = current_line.len();
3079                        current_line.push_str(&element_str);
3080                        current_length += element_len;
3081                        current_line_element_spans.push((start, current_line.len()));
3082                    }
3083                } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
3084                    if !starts_block_construct(&element_str) {
3085                        // Not adjacent, would exceed — start new line
3086                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3087                        current_line.clone_from(&element_str);
3088                        current_length = element_len;
3089                        current_line_element_spans.clear();
3090                        current_line_element_spans.push((0, element_str.len()));
3091                    } else if let Some(carried) = break_before_attached(
3092                        &mut lines,
3093                        &mut current_line,
3094                        &mut current_length,
3095                        &mut current_line_element_spans,
3096                        &element_str,
3097                        " ",
3098                        length_mode,
3099                    ) {
3100                        // The overflowing element would open a block construct at
3101                        // line start (e.g. an HtmlTag like `<div>`). Broke one word
3102                        // earlier instead so the element stays mid-line.
3103                        let start = carried + 1;
3104                        current_line_element_spans.push((start, start + element_str.len()));
3105                    } else {
3106                        // No safe earlier break point — keep the element attached
3107                        // and accept the long line rather than corrupt the structure.
3108                        let ends_with_opener =
3109                            current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3110                        if !ends_with_opener {
3111                            current_line.push(' ');
3112                            current_length += 1;
3113                        }
3114                        let start = current_line.len();
3115                        current_line.push_str(&element_str);
3116                        current_length += element_len;
3117                        current_line_element_spans.push((start, current_line.len()));
3118                    }
3119                } else {
3120                    // Not adjacent, fits — add with space
3121                    let ends_with_opener =
3122                        current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3123                    if current_length > 0 && !ends_with_opener {
3124                        current_line.push(' ');
3125                        current_length += 1;
3126                    }
3127                    let start = current_line.len();
3128                    current_line.push_str(&element_str);
3129                    current_length += element_len;
3130                    current_line_element_spans.push((start, current_line.len()));
3131                }
3132            }
3133        }
3134    }
3135
3136    // Don't forget the last line
3137    if !current_line.is_empty() {
3138        lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3139    }
3140
3141    lines
3142}
3143
3144/// Reflow markdown content preserving structure
3145pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3146    let lines: Vec<&str> = content.lines().collect();
3147    let mut result = Vec::new();
3148    let mut i = 0;
3149
3150    while i < lines.len() {
3151        let line = lines[i];
3152        let trimmed = line.trim();
3153
3154        // Preserve empty lines
3155        if trimmed.is_empty() {
3156            result.push(String::new());
3157            i += 1;
3158            continue;
3159        }
3160
3161        // Preserve headings as-is
3162        if trimmed.starts_with('#') {
3163            result.push(line.to_string());
3164            i += 1;
3165            continue;
3166        }
3167
3168        // Preserve Quarto/Pandoc div markers (:::) as-is
3169        if trimmed.starts_with(":::") {
3170            result.push(line.to_string());
3171            i += 1;
3172            continue;
3173        }
3174
3175        // Preserve fenced code blocks
3176        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3177            result.push(line.to_string());
3178            i += 1;
3179            // Copy lines until closing fence
3180            while i < lines.len() {
3181                result.push(lines[i].to_string());
3182                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3183                    i += 1;
3184                    break;
3185                }
3186                i += 1;
3187            }
3188            continue;
3189        }
3190
3191        // Preserve indented code blocks (4+ columns accounting for tab expansion)
3192        if calculate_indentation_width_default(line) >= 4 {
3193            // Collect all consecutive indented lines
3194            result.push(line.to_string());
3195            i += 1;
3196            while i < lines.len() {
3197                let next_line = lines[i];
3198                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
3199                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3200                    result.push(next_line.to_string());
3201                    i += 1;
3202                } else {
3203                    break;
3204                }
3205            }
3206            continue;
3207        }
3208
3209        // Preserve block quotes (but reflow their content)
3210        if trimmed.starts_with('>') {
3211            // find() returns byte position which is correct for str slicing
3212            // The unwrap is safe because we already verified trimmed starts with '>'
3213            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3214            let quote_prefix = line[0..=gt_pos].to_string();
3215            let quote_content = &line[quote_prefix.len()..].trim_start();
3216
3217            let reflowed = reflow_line(quote_content, options);
3218            for reflowed_line in &reflowed {
3219                result.push(format!("{quote_prefix} {reflowed_line}"));
3220            }
3221            i += 1;
3222            continue;
3223        }
3224
3225        // Preserve horizontal rules first (before checking for lists)
3226        if is_horizontal_rule(trimmed) {
3227            result.push(line.to_string());
3228            i += 1;
3229            continue;
3230        }
3231
3232        // Preserve lists (but not horizontal rules)
3233        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3234            // Find the list marker and preserve indentation
3235            let indent = line.len() - line.trim_start().len();
3236            let indent_str = " ".repeat(indent);
3237
3238            // For numbered lists, find the period and the space after it
3239            // For bullet lists, find the marker and the space after it
3240            let mut marker_end = indent;
3241            let mut content_start = indent;
3242
3243            if trimmed.chars().next().is_some_and(char::is_numeric) {
3244                // Numbered list: find the period
3245                if let Some(period_pos) = line[indent..].find('.') {
3246                    marker_end = indent + period_pos + 1; // Include the period
3247                    content_start = marker_end;
3248                    // Skip any spaces after the period to find content start
3249                    // Use byte-based check since content_start is a byte index
3250                    // This is safe because space is ASCII (single byte)
3251                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3252                        content_start += 1;
3253                    }
3254                }
3255            } else {
3256                // Bullet list: marker is single character
3257                marker_end = indent + 1; // Just the marker character
3258                content_start = marker_end;
3259                // Skip any spaces after the marker
3260                // Use byte-based check since content_start is a byte index
3261                // This is safe because space is ASCII (single byte)
3262                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3263                    content_start += 1;
3264                }
3265            }
3266
3267            // Minimum indent for continuation lines (based on list marker, before checkbox)
3268            let min_continuation_indent = content_start;
3269
3270            // Detect checkbox/task list markers: [ ], [x], [X]
3271            // GFM task lists work with both unordered and ordered lists
3272            let rest = &line[content_start..];
3273            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3274                marker_end = content_start + 3; // Include the checkbox `[ ]`
3275                content_start += 4; // Skip past `[ ] `
3276            }
3277
3278            let marker = &line[indent..marker_end];
3279
3280            // Collect all content for this list item (including continuation lines)
3281            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
3282            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3283            i += 1;
3284
3285            // Collect continuation lines (indented lines that are part of this list item)
3286            // Use the base marker indent (not checkbox-extended) for collection,
3287            // since users may indent continuations to the bullet level, not the checkbox level
3288            while i < lines.len() {
3289                let next_line = lines[i];
3290                let next_trimmed = next_line.trim();
3291
3292                // Stop if we hit an empty line or another list item or special block
3293                if is_block_boundary(next_trimmed) {
3294                    break;
3295                }
3296
3297                // Check if this line is indented (continuation of list item)
3298                let next_indent = next_line.len() - next_line.trim_start().len();
3299                if next_indent >= min_continuation_indent {
3300                    // This is a continuation line - add its content
3301                    // Preserve hard breaks while trimming excessive whitespace
3302                    let trimmed_start = next_line.trim_start();
3303                    list_content.push(trim_preserving_hard_break(trimmed_start));
3304                    i += 1;
3305                } else {
3306                    // Not indented enough, not part of this list item
3307                    break;
3308                }
3309            }
3310
3311            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
3312            // Hard breaks should prevent joining with the next line
3313            let combined_content = if options.preserve_breaks {
3314                list_content[0].clone()
3315            } else {
3316                // Check if any lines have hard breaks - if so, preserve the structure
3317                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3318                if has_hard_breaks {
3319                    // Don't join lines with hard breaks - keep them separate with newlines
3320                    list_content.join("\n")
3321                } else {
3322                    // No hard breaks, safe to join with spaces
3323                    list_content.join(" ")
3324                }
3325            };
3326
3327            // Calculate the proper indentation for continuation lines
3328            let trimmed_marker = marker;
3329            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3330                // Cap the relative indent (past the nesting level) to max_indent,
3331                // then add back the nesting indent so nested items stay correct
3332                indent + (content_start - indent).min(max_indent)
3333            } else {
3334                content_start
3335            };
3336
3337            // Adjust line length to account for list marker and space
3338            let prefix_length = indent + trimmed_marker.len() + 1;
3339
3340            // Create adjusted options with reduced line length
3341            let adjusted_options = ReflowOptions {
3342                line_length: options.line_length.saturating_sub(prefix_length),
3343                ..options.clone()
3344            };
3345
3346            let reflowed = reflow_line(&combined_content, &adjusted_options);
3347            for (j, reflowed_line) in reflowed.iter().enumerate() {
3348                if j == 0 {
3349                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3350                } else {
3351                    // Continuation lines aligned with text after marker
3352                    let continuation_indent = " ".repeat(continuation_spaces);
3353                    result.push(format!("{continuation_indent}{reflowed_line}"));
3354                }
3355            }
3356            continue;
3357        }
3358
3359        // Preserve tables
3360        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3361            result.push(line.to_string());
3362            i += 1;
3363            continue;
3364        }
3365
3366        // Preserve reference definitions
3367        if trimmed.starts_with('[') && line.contains("]:") {
3368            result.push(line.to_string());
3369            i += 1;
3370            continue;
3371        }
3372
3373        // Preserve definition list items (extended markdown)
3374        if is_definition_list_item(trimmed) {
3375            result.push(line.to_string());
3376            i += 1;
3377            continue;
3378        }
3379
3380        // Check if this is a single line that doesn't need processing
3381        let mut is_single_line_paragraph = true;
3382        if i + 1 < lines.len() {
3383            let next_trimmed = lines[i + 1].trim();
3384            // Check if next line continues this paragraph
3385            if !is_block_boundary(next_trimmed) {
3386                is_single_line_paragraph = false;
3387            }
3388        }
3389
3390        // If it's a single line that fits, just add it as-is
3391        if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
3392            result.push(line.to_string());
3393            i += 1;
3394            continue;
3395        }
3396
3397        // For regular paragraphs, collect consecutive lines
3398        let mut paragraph_parts = Vec::new();
3399        let mut current_part = vec![line];
3400        i += 1;
3401
3402        // If preserve_breaks is true, treat each line separately
3403        if options.preserve_breaks {
3404            // Don't collect consecutive lines - just reflow this single line
3405            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3406                Some("\\")
3407            } else if line.ends_with("  ") {
3408                Some("  ")
3409            } else {
3410                None
3411            };
3412            let reflowed = reflow_line(line, options);
3413
3414            // Preserve hard breaks (two trailing spaces or backslash)
3415            if let Some(break_marker) = hard_break_type {
3416                if !reflowed.is_empty() {
3417                    let mut reflowed_with_break = reflowed;
3418                    let last_idx = reflowed_with_break.len() - 1;
3419                    if !has_hard_break(&reflowed_with_break[last_idx]) {
3420                        reflowed_with_break[last_idx].push_str(break_marker);
3421                    }
3422                    result.extend(reflowed_with_break);
3423                }
3424            } else {
3425                result.extend(reflowed);
3426            }
3427        } else {
3428            // Original behavior: collect consecutive lines into a paragraph
3429            while i < lines.len() {
3430                let prev_line = if !current_part.is_empty() {
3431                    current_part.last().unwrap()
3432                } else {
3433                    ""
3434                };
3435                let next_line = lines[i];
3436                let next_trimmed = next_line.trim();
3437
3438                // Stop at empty lines or special blocks
3439                if is_block_boundary(next_trimmed) {
3440                    break;
3441                }
3442
3443                // Check if previous line ends with hard break (two spaces or backslash)
3444                // or is a complete sentence in sentence_per_line mode
3445                let prev_trimmed = prev_line.trim();
3446                let abbreviations = get_abbreviations(&options.abbreviations);
3447                let ends_with_sentence = (prev_trimmed.ends_with('.')
3448                    || prev_trimmed.ends_with('!')
3449                    || prev_trimmed.ends_with('?')
3450                    || prev_trimmed.ends_with(".*")
3451                    || prev_trimmed.ends_with("!*")
3452                    || prev_trimmed.ends_with("?*")
3453                    || prev_trimmed.ends_with("._")
3454                    || prev_trimmed.ends_with("!_")
3455                    || prev_trimmed.ends_with("?_")
3456                    // Quote-terminated sentences (straight and curly quotes)
3457                    || 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(".\u{201D}")
3464                    || prev_trimmed.ends_with("!\u{201D}")
3465                    || prev_trimmed.ends_with("?\u{201D}")
3466                    || prev_trimmed.ends_with(".\u{2019}")
3467                    || prev_trimmed.ends_with("!\u{2019}")
3468                    || prev_trimmed.ends_with("?\u{2019}"))
3469                    && !text_ends_with_abbreviation(
3470                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3471                        &abbreviations,
3472                    );
3473
3474                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3475                    // Start a new part after hard break or complete sentence
3476                    paragraph_parts.push(current_part.join(" "));
3477                    current_part = vec![next_line];
3478                } else {
3479                    current_part.push(next_line);
3480                }
3481                i += 1;
3482            }
3483
3484            // Add the last part
3485            if !current_part.is_empty() {
3486                if current_part.len() == 1 {
3487                    // Single line, don't add trailing space
3488                    paragraph_parts.push(current_part[0].to_string());
3489                } else {
3490                    paragraph_parts.push(current_part.join(" "));
3491                }
3492            }
3493
3494            // Reflow each part separately, preserving hard breaks
3495            for (j, part) in paragraph_parts.iter().enumerate() {
3496                let reflowed = reflow_line(part, options);
3497                result.extend(reflowed);
3498
3499                // Preserve hard break by ensuring last line of part ends with hard break marker
3500                // Use two spaces as the default hard break format for reflows
3501                // But don't add hard breaks in sentence_per_line mode - lines are already separate
3502                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
3503                    let last_idx = result.len() - 1;
3504                    if !has_hard_break(&result[last_idx]) {
3505                        result[last_idx].push_str("  ");
3506                    }
3507                }
3508            }
3509        }
3510    }
3511
3512    // Preserve trailing newline if the original content had one
3513    let result_text = result.join("\n");
3514    if content.ends_with('\n') && !result_text.ends_with('\n') {
3515        format!("{result_text}\n")
3516    } else {
3517        result_text
3518    }
3519}
3520
3521/// Information about a reflowed paragraph
3522#[derive(Debug, Clone)]
3523pub struct ParagraphReflow {
3524    /// Starting byte offset of the paragraph in the original content
3525    pub start_byte: usize,
3526    /// Ending byte offset of the paragraph in the original content
3527    pub end_byte: usize,
3528    /// The reflowed text for this paragraph
3529    pub reflowed_text: String,
3530}
3531
3532/// A collected blockquote line used for style-preserving reflow.
3533///
3534/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
3535/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
3536/// rather than constructing the struct directly.
3537#[derive(Debug, Clone)]
3538pub struct BlockquoteLineData {
3539    /// Trimmed content without the `> ` prefix.
3540    pub(crate) content: String,
3541    /// Whether this line carries an explicit blockquote marker.
3542    pub(crate) is_explicit: bool,
3543    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
3544    pub(crate) prefix: Option<String>,
3545}
3546
3547impl BlockquoteLineData {
3548    /// Create an explicit (marker-bearing) blockquote line.
3549    pub fn explicit(content: String, prefix: String) -> Self {
3550        Self {
3551            content,
3552            is_explicit: true,
3553            prefix: Some(prefix),
3554        }
3555    }
3556
3557    /// Create a lazy continuation line (no blockquote marker).
3558    pub fn lazy(content: String) -> Self {
3559        Self {
3560            content,
3561            is_explicit: false,
3562            prefix: None,
3563        }
3564    }
3565}
3566
3567/// Style for blockquote continuation lines after reflow.
3568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3569pub enum BlockquoteContinuationStyle {
3570    Explicit,
3571    Lazy,
3572}
3573
3574/// Determine the continuation style for a blockquote paragraph from its collected lines.
3575///
3576/// The first line is always explicit (it carries the marker), so only continuation
3577/// lines (index 1+) are counted. Ties resolve to `Explicit`.
3578///
3579/// When the slice has only one element (no continuation lines to inspect), both
3580/// counts are zero and the tie-breaking rule returns `Explicit`.
3581pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
3582    let mut explicit_count = 0usize;
3583    let mut lazy_count = 0usize;
3584
3585    for line in lines.iter().skip(1) {
3586        if line.is_explicit {
3587            explicit_count += 1;
3588        } else {
3589            lazy_count += 1;
3590        }
3591    }
3592
3593    if explicit_count > 0 && lazy_count == 0 {
3594        BlockquoteContinuationStyle::Explicit
3595    } else if lazy_count > 0 && explicit_count == 0 {
3596        BlockquoteContinuationStyle::Lazy
3597    } else if explicit_count >= lazy_count {
3598        BlockquoteContinuationStyle::Explicit
3599    } else {
3600        BlockquoteContinuationStyle::Lazy
3601    }
3602}
3603
3604/// Determine the dominant blockquote prefix for a paragraph.
3605///
3606/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
3607/// first appearance. Falls back to `fallback` when no explicit lines are present.
3608pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
3609    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
3610
3611    for (idx, line) in lines.iter().enumerate() {
3612        let Some(prefix) = line.prefix.as_ref() else {
3613            continue;
3614        };
3615        counts
3616            .entry(prefix.clone())
3617            .and_modify(|entry| entry.0 += 1)
3618            .or_insert((1, idx));
3619    }
3620
3621    counts
3622        .into_iter()
3623        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
3624            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
3625        })
3626        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
3627}
3628
3629/// Whether a reflowed blockquote content line must carry an explicit prefix.
3630///
3631/// Lines that would start a new block structure (headings, fences, lists, etc.)
3632/// cannot safely use lazy continuation syntax.
3633pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
3634    let trimmed = content_line.trim_start();
3635    trimmed.starts_with('>')
3636        || trimmed.starts_with('#')
3637        || trimmed.starts_with("```")
3638        || trimmed.starts_with("~~~")
3639        || is_unordered_list_marker(trimmed)
3640        || is_numbered_list_item(trimmed)
3641        || is_horizontal_rule(trimmed)
3642        || is_definition_list_item(trimmed)
3643        || (trimmed.starts_with('[') && trimmed.contains("]:"))
3644        || trimmed.starts_with(":::")
3645        || (trimmed.starts_with('<')
3646            && !trimmed.starts_with("<http")
3647            && !trimmed.starts_with("<https")
3648            && !trimmed.starts_with("<mailto:"))
3649}
3650
3651/// Reflow blockquote content lines and apply continuation style.
3652///
3653/// Segments separated by hard breaks are reflowed independently. The output lines
3654/// receive blockquote prefixes according to `continuation_style`: the first line and
3655/// any line that would start a new block structure always get an explicit prefix;
3656/// other lines follow the detected style.
3657///
3658/// Returns the styled, reflowed lines (without a trailing newline).
3659pub fn reflow_blockquote_content(
3660    lines: &[BlockquoteLineData],
3661    explicit_prefix: &str,
3662    continuation_style: BlockquoteContinuationStyle,
3663    options: &ReflowOptions,
3664) -> Vec<String> {
3665    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
3666    let segments = split_into_segments_strs(&content_strs);
3667    let mut reflowed_content_lines: Vec<String> = Vec::new();
3668
3669    for segment in segments {
3670        let hard_break_type = segment.last().and_then(|&line| {
3671            let line = line.strip_suffix('\r').unwrap_or(line);
3672            if line.ends_with('\\') {
3673                Some("\\")
3674            } else if line.ends_with("  ") {
3675                Some("  ")
3676            } else {
3677                None
3678            }
3679        });
3680
3681        let pieces: Vec<&str> = segment
3682            .iter()
3683            .map(|&line| {
3684                if let Some(l) = line.strip_suffix('\\') {
3685                    l.trim_end()
3686                } else if let Some(l) = line.strip_suffix("  ") {
3687                    l.trim_end()
3688                } else {
3689                    line.trim_end()
3690                }
3691            })
3692            .collect();
3693
3694        let segment_text = pieces.join(" ");
3695        let segment_text = segment_text.trim();
3696        if segment_text.is_empty() {
3697            continue;
3698        }
3699
3700        let mut reflowed = reflow_line(segment_text, options);
3701        if let Some(break_marker) = hard_break_type
3702            && !reflowed.is_empty()
3703        {
3704            let last_idx = reflowed.len() - 1;
3705            if !has_hard_break(&reflowed[last_idx]) {
3706                reflowed[last_idx].push_str(break_marker);
3707            }
3708        }
3709        reflowed_content_lines.extend(reflowed);
3710    }
3711
3712    let mut styled_lines: Vec<String> = Vec::new();
3713    for (idx, line) in reflowed_content_lines.iter().enumerate() {
3714        let force_explicit = idx == 0
3715            || continuation_style == BlockquoteContinuationStyle::Explicit
3716            || should_force_explicit_blockquote_line(line);
3717        if force_explicit {
3718            styled_lines.push(format!("{explicit_prefix}{line}"));
3719        } else {
3720            styled_lines.push(line.clone());
3721        }
3722    }
3723
3724    styled_lines
3725}
3726
3727fn is_blockquote_content_boundary(content: &str) -> bool {
3728    let trimmed = content.trim();
3729    trimmed.is_empty()
3730        || is_block_boundary(trimmed)
3731        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
3732        || trimmed.starts_with(":::")
3733        || crate::utils::is_template_directive_only(content)
3734        || is_standalone_attr_list(content)
3735        || is_snippet_block_delimiter(content)
3736}
3737
3738fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
3739    let mut segments = Vec::new();
3740    let mut current = Vec::new();
3741
3742    for &line in lines {
3743        current.push(line);
3744        if has_hard_break(line) {
3745            segments.push(current);
3746            current = Vec::new();
3747        }
3748    }
3749
3750    if !current.is_empty() {
3751        segments.push(current);
3752    }
3753
3754    segments
3755}
3756
3757fn reflow_blockquote_paragraph_at_line(
3758    content: &str,
3759    lines: &[&str],
3760    target_idx: usize,
3761    options: &ReflowOptions,
3762) -> Option<ParagraphReflow> {
3763    let mut anchor_idx = target_idx;
3764    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3765        parsed.nesting_level
3766    } else {
3767        let mut found = None;
3768        let mut idx = target_idx;
3769        loop {
3770            if lines[idx].trim().is_empty() {
3771                break;
3772            }
3773            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3774                found = Some((idx, parsed.nesting_level));
3775                break;
3776            }
3777            if idx == 0 {
3778                break;
3779            }
3780            idx -= 1;
3781        }
3782        let (idx, level) = found?;
3783        anchor_idx = idx;
3784        level
3785    };
3786
3787    // Expand backward to capture prior quote content at the same nesting level.
3788    let mut para_start = anchor_idx;
3789    while para_start > 0 {
3790        let prev_idx = para_start - 1;
3791        let prev_line = lines[prev_idx];
3792
3793        if prev_line.trim().is_empty() {
3794            break;
3795        }
3796
3797        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3798            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3799                break;
3800            }
3801            para_start = prev_idx;
3802            continue;
3803        }
3804
3805        let prev_lazy = prev_line.trim_start();
3806        if is_blockquote_content_boundary(prev_lazy) {
3807            break;
3808        }
3809        para_start = prev_idx;
3810    }
3811
3812    // Lazy continuation cannot precede the first explicit marker.
3813    while para_start < lines.len() {
3814        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3815            para_start += 1;
3816            continue;
3817        };
3818        target_level = parsed.nesting_level;
3819        break;
3820    }
3821
3822    if para_start >= lines.len() || para_start > target_idx {
3823        return None;
3824    }
3825
3826    // Collect explicit lines at target level and lazy continuation lines.
3827    // Each entry is (original_line_idx, BlockquoteLineData).
3828    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3829    let mut idx = para_start;
3830    while idx < lines.len() {
3831        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3832            break;
3833        }
3834
3835        let line = lines[idx];
3836        if line.trim().is_empty() {
3837            break;
3838        }
3839
3840        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3841            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3842                break;
3843            }
3844            collected.push((
3845                idx,
3846                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3847            ));
3848            idx += 1;
3849            continue;
3850        }
3851
3852        let lazy_content = line.trim_start();
3853        if is_blockquote_content_boundary(lazy_content) {
3854            break;
3855        }
3856
3857        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3858        idx += 1;
3859    }
3860
3861    if collected.is_empty() {
3862        return None;
3863    }
3864
3865    let para_end = collected[collected.len() - 1].0;
3866    if target_idx < para_start || target_idx > para_end {
3867        return None;
3868    }
3869
3870    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3871
3872    let fallback_prefix = line_data
3873        .iter()
3874        .find_map(|d| d.prefix.clone())
3875        .unwrap_or_else(|| "> ".to_string());
3876    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3877    let continuation_style = blockquote_continuation_style(&line_data);
3878
3879    let adjusted_line_length = options
3880        .line_length
3881        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3882        .max(1);
3883
3884    let adjusted_options = ReflowOptions {
3885        line_length: adjusted_line_length,
3886        ..options.clone()
3887    };
3888
3889    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3890
3891    if styled_lines.is_empty() {
3892        return None;
3893    }
3894
3895    // Calculate byte offsets.
3896    let mut start_byte = 0;
3897    for line in lines.iter().take(para_start) {
3898        start_byte += line.len() + 1;
3899    }
3900
3901    let mut end_byte = start_byte;
3902    for line in lines.iter().take(para_end + 1).skip(para_start) {
3903        end_byte += line.len() + 1;
3904    }
3905
3906    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3907    if !includes_trailing_newline {
3908        end_byte -= 1;
3909    }
3910
3911    let reflowed_joined = styled_lines.join("\n");
3912    let reflowed_text = if includes_trailing_newline {
3913        if reflowed_joined.ends_with('\n') {
3914            reflowed_joined
3915        } else {
3916            format!("{reflowed_joined}\n")
3917        }
3918    } else if reflowed_joined.ends_with('\n') {
3919        reflowed_joined.trim_end_matches('\n').to_string()
3920    } else {
3921        reflowed_joined
3922    };
3923
3924    Some(ParagraphReflow {
3925        start_byte,
3926        end_byte,
3927        reflowed_text,
3928    })
3929}
3930
3931/// Reflow a single paragraph at the specified line number
3932///
3933/// This function finds the paragraph containing the given line number,
3934/// reflows it according to the specified line length, and returns
3935/// information about the paragraph location and its reflowed text.
3936///
3937/// # Arguments
3938///
3939/// * `content` - The full document content
3940/// * `line_number` - The 1-based line number within the paragraph to reflow
3941/// * `line_length` - The target line length for reflowing
3942///
3943/// # Returns
3944///
3945/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
3946/// or `None` if the line number is out of bounds or the content at that
3947/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
3948pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3949    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3950}
3951
3952/// Reflow a paragraph at the given line with a specific length mode.
3953pub fn reflow_paragraph_at_line_with_mode(
3954    content: &str,
3955    line_number: usize,
3956    line_length: usize,
3957    length_mode: ReflowLengthMode,
3958) -> Option<ParagraphReflow> {
3959    let options = ReflowOptions {
3960        line_length,
3961        length_mode,
3962        ..Default::default()
3963    };
3964    reflow_paragraph_at_line_with_options(content, line_number, &options)
3965}
3966
3967/// Reflow a paragraph at the given line using the provided options.
3968///
3969/// This is the canonical implementation used by both the rule's fix mode and the
3970/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
3971/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
3972///
3973/// # Returns
3974///
3975/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
3976/// if the line is out of bounds or sits inside a non-reflow-able construct.
3977pub fn reflow_paragraph_at_line_with_options(
3978    content: &str,
3979    line_number: usize,
3980    options: &ReflowOptions,
3981) -> Option<ParagraphReflow> {
3982    if line_number == 0 {
3983        return None;
3984    }
3985
3986    let lines: Vec<&str> = content.lines().collect();
3987
3988    // Check if line number is valid (1-based)
3989    if line_number > lines.len() {
3990        return None;
3991    }
3992
3993    let target_idx = line_number - 1; // Convert to 0-based
3994    let target_line = lines[target_idx];
3995    let trimmed = target_line.trim();
3996
3997    // Handle blockquote paragraphs (including lazy continuation lines) with
3998    // style-preserving output.
3999    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4000        return Some(blockquote_reflow);
4001    }
4002
4003    // Don't reflow special blocks
4004    if is_paragraph_boundary(trimmed, target_line) {
4005        return None;
4006    }
4007
4008    // Find paragraph start - scan backward until blank line or special block
4009    let mut para_start = target_idx;
4010    while para_start > 0 {
4011        let prev_idx = para_start - 1;
4012        let prev_line = lines[prev_idx];
4013        let prev_trimmed = prev_line.trim();
4014
4015        // Stop at blank line or special blocks
4016        if is_paragraph_boundary(prev_trimmed, prev_line) {
4017            break;
4018        }
4019
4020        para_start = prev_idx;
4021    }
4022
4023    // Find paragraph end - scan forward until blank line or special block
4024    let mut para_end = target_idx;
4025    while para_end + 1 < lines.len() {
4026        let next_idx = para_end + 1;
4027        let next_line = lines[next_idx];
4028        let next_trimmed = next_line.trim();
4029
4030        // Stop at blank line or special blocks
4031        if is_paragraph_boundary(next_trimmed, next_line) {
4032            break;
4033        }
4034
4035        para_end = next_idx;
4036    }
4037
4038    // Extract paragraph lines
4039    let paragraph_lines = &lines[para_start..=para_end];
4040
4041    // Calculate byte offsets
4042    let mut start_byte = 0;
4043    for line in lines.iter().take(para_start) {
4044        start_byte += line.len() + 1; // +1 for newline
4045    }
4046
4047    let mut end_byte = start_byte;
4048    for line in paragraph_lines {
4049        end_byte += line.len() + 1; // +1 for newline
4050    }
4051
4052    // Track whether the byte range includes a trailing newline
4053    // (it doesn't if this is the last line and the file doesn't end with newline)
4054    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4055
4056    // Adjust end_byte if the last line doesn't have a newline
4057    if !includes_trailing_newline {
4058        end_byte -= 1;
4059    }
4060
4061    // Join paragraph lines and reflow
4062    let paragraph_text = paragraph_lines.join("\n");
4063
4064    // Reflow the paragraph using reflow_markdown to handle it properly
4065    let reflowed = reflow_markdown(&paragraph_text, options);
4066
4067    // Ensure reflowed text matches whether the byte range includes a trailing newline
4068    // This is critical: if the range includes a newline, the replacement must too,
4069    // otherwise the next line will get appended to the reflowed paragraph
4070    let reflowed_text = if includes_trailing_newline {
4071        // Range includes newline - ensure reflowed text has one
4072        if reflowed.ends_with('\n') {
4073            reflowed
4074        } else {
4075            format!("{reflowed}\n")
4076        }
4077    } else {
4078        // Range doesn't include newline - ensure reflowed text doesn't have one
4079        if reflowed.ends_with('\n') {
4080            reflowed.trim_end_matches('\n').to_string()
4081        } else {
4082            reflowed
4083        }
4084    };
4085
4086    Some(ParagraphReflow {
4087        start_byte,
4088        end_byte,
4089        reflowed_text,
4090    })
4091}
4092/// Decomposes a raw inline code span string into its inner content and backtick marker.
4093///
4094/// For example, `decompose_code_span("`code`")` returns `Some(("code", "`"))`.
4095/// If the input is not a valid code span (e.g., it doesn't start and end with the
4096/// same number of backticks), returns `None`.
4097fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4098    let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4099    if marker_len == 0 {
4100        return None;
4101    }
4102    let marker = &raw[..marker_len];
4103    if raw.len() < marker_len * 2 {
4104        return None;
4105    }
4106    let content = &raw[marker_len..raw.len() - marker_len];
4107    Some((content, marker))
4108}
4109
4110#[cfg(test)]
4111mod tests {
4112    use super::*;
4113
4114    #[test]
4115    fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4116        // A single line of thousands of words once drove `cascade_split_line`
4117        // into deep recursion (stack overflow / hang). The iterative version
4118        // must complete and split it into many lines that each fit the width and
4119        // that together preserve every word. The test finishing at all is the
4120        // core assertion (no stack overflow); the content checks guard behavior.
4121        let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4122        let line = words.join(" ");
4123
4124        let options = ReflowOptions {
4125            line_length: 80,
4126            length_mode: ReflowLengthMode::Chars,
4127            ..Default::default()
4128        };
4129        let out = cascade_split_line(&line, &options);
4130
4131        assert!(out.len() > 1, "a very long line should split into many lines");
4132        for segment in &out {
4133            assert!(
4134                display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4135                "each wrapped line should fit the width (or be a single unbreakable token)"
4136            );
4137        }
4138        // Every original word survives, in order.
4139        let rejoined = out.join(" ");
4140        let original_words: Vec<&str> = line.split(' ').collect();
4141        let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4142        assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4143    }
4144
4145    /// Unit test for private helper function text_ends_with_abbreviation()
4146    ///
4147    /// This test stays inline because it tests a private function.
4148    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
4149    #[test]
4150    fn test_helper_function_text_ends_with_abbreviation() {
4151        // Test the helper function directly
4152        let abbreviations = get_abbreviations(&None);
4153
4154        // True cases - built-in abbreviations (titles and i.e./e.g.)
4155        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4156        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4157        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4158        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4159        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4160        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4161        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4162        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4163
4164        // False cases - NOT in built-in list (etc doesn't always have period)
4165        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4166        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4167        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4168        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4169        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4170        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
4171        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
4172        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
4173        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
4174        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
4175    }
4176
4177    #[test]
4178    fn test_footnote_after_period_splits_sentence() {
4179        // A footnote reference glued to the period (no space) must not swallow
4180        // the sentence boundary; the reference stays attached to the sentence
4181        // it annotates.
4182        let text = "First sentence.[^1] Second sentence.";
4183        let sentences = split_into_sentences(text);
4184        assert_eq!(
4185            sentences,
4186            vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4187            "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4188        );
4189    }
4190
4191    #[test]
4192    fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4193        // Multiple footnote references glued back-to-back after the period.
4194        let text = "Notes here.[^1][^2] Second sentence.";
4195        let sentences = split_into_sentences(text);
4196        assert_eq!(
4197            sentences,
4198            vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4199        );
4200    }
4201
4202    #[test]
4203    fn test_footnote_before_period_still_splits_sentence() {
4204        // Control: a footnote reference before the period was already followed
4205        // by a space, so this boundary worked before this fix and must keep
4206        // working.
4207        let text = "Annotation here[^1]. Second sentence.";
4208        let sentences = split_into_sentences(text);
4209        assert_eq!(
4210            sentences,
4211            vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4212        );
4213    }
4214
4215    #[test]
4216    fn test_mid_sentence_footnote_does_not_split() {
4217        // A footnote reference not glued to sentence-ending punctuation must not
4218        // introduce a spurious boundary at the bracket itself.
4219        let text = "The system word[^1] more words. Next sentence.";
4220        let sentences = split_into_sentences(text);
4221        assert_eq!(
4222            sentences,
4223            vec![
4224                "The system word[^1] more words.".to_string(),
4225                "Next sentence.".to_string()
4226            ]
4227        );
4228    }
4229
4230    #[test]
4231    fn test_bare_numeric_bracket_after_period_does_not_split() {
4232        // A bare `[1]` is link/citation-like text, not footnote syntax; the fix
4233        // is scoped to `[^label]` only.
4234        let text = "Citation here.[1] Second sentence.";
4235        let sentences = split_into_sentences(text);
4236        assert_eq!(
4237            sentences,
4238            vec![text.to_string()],
4239            "a bare numeric bracket must not be treated as a sentence boundary"
4240        );
4241    }
4242
4243    #[test]
4244    fn test_footnote_glued_to_following_word_does_not_split() {
4245        // No whitespace after the footnote reference means there is nowhere a
4246        // next sentence can start, so this must not be treated as a boundary.
4247        let text = "First sentence.[^1]Continued glued text.";
4248        let sentences = split_into_sentences(text);
4249        assert_eq!(sentences, vec![text.to_string()]);
4250    }
4251
4252    #[test]
4253    fn test_footnote_at_end_of_text_is_preserved() {
4254        // A footnote reference at the very end of the text has nothing after it
4255        // to split off; it is preserved as part of the single trailing sentence.
4256        let text = "Sentence.[^1]";
4257        let sentences = split_into_sentences(text);
4258        assert_eq!(sentences, vec![text.to_string()]);
4259    }
4260
4261    #[test]
4262    fn test_abbreviation_before_footnote_does_not_split() {
4263        // The existing abbreviation guard must still apply when a footnote
4264        // reference immediately follows the abbreviation's period.
4265        let text = "See the notes, e.g.[^1] this one.";
4266        let sentences = split_into_sentences(text);
4267        assert_eq!(
4268            sentences,
4269            vec![text.to_string()],
4270            "e.g. is an abbreviation, not a sentence boundary"
4271        );
4272    }
4273
4274    #[test]
4275    fn test_is_unordered_list_marker() {
4276        // Valid unordered list markers
4277        assert!(is_unordered_list_marker("- item"));
4278        assert!(is_unordered_list_marker("* item"));
4279        assert!(is_unordered_list_marker("+ item"));
4280        assert!(is_unordered_list_marker("-")); // lone marker
4281        assert!(is_unordered_list_marker("*"));
4282        assert!(is_unordered_list_marker("+"));
4283
4284        // Not list markers
4285        assert!(!is_unordered_list_marker("---")); // horizontal rule
4286        assert!(!is_unordered_list_marker("***")); // horizontal rule
4287        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
4288        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
4289        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
4290        assert!(!is_unordered_list_marker("-word")); // no space after marker
4291        assert!(!is_unordered_list_marker("")); // empty
4292        assert!(!is_unordered_list_marker("text")); // plain text
4293        assert!(!is_unordered_list_marker("# heading")); // heading
4294    }
4295
4296    #[test]
4297    fn test_is_block_boundary() {
4298        // Block boundaries
4299        assert!(is_block_boundary("")); // empty line
4300        assert!(is_block_boundary("# Heading")); // ATX heading
4301        assert!(is_block_boundary("## Level 2")); // ATX heading
4302        assert!(is_block_boundary("```rust")); // code fence
4303        assert!(is_block_boundary("~~~")); // tilde code fence
4304        assert!(is_block_boundary("> quote")); // blockquote
4305        assert!(is_block_boundary("| cell |")); // table
4306        assert!(is_block_boundary("[link]: http://example.com")); // reference def
4307        assert!(is_block_boundary("---")); // horizontal rule
4308        assert!(is_block_boundary("***")); // horizontal rule
4309        assert!(is_block_boundary("- item")); // unordered list
4310        assert!(is_block_boundary("* item")); // unordered list
4311        assert!(is_block_boundary("+ item")); // unordered list
4312        assert!(is_block_boundary("1. item")); // ordered list
4313        assert!(is_block_boundary("10. item")); // ordered list
4314        assert!(is_block_boundary(": definition")); // definition list
4315        assert!(is_block_boundary(":::")); // div marker
4316        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
4317
4318        // NOT block boundaries (paragraph continuation)
4319        assert!(!is_block_boundary("regular text"));
4320        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
4321        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
4322        assert!(!is_block_boundary("some words here"));
4323    }
4324
4325    #[test]
4326    fn test_definition_list_boundary_in_single_line_paragraph() {
4327        // Verifies that a definition list item after a single-line paragraph
4328        // is treated as a block boundary, not merged into the paragraph
4329        let options = ReflowOptions {
4330            line_length: 80,
4331            ..Default::default()
4332        };
4333        let input = "Term\n: Definition of the term";
4334        let result = reflow_markdown(input, &options);
4335        // The definition list marker should remain on its own line
4336        assert!(
4337            result.contains(": Definition"),
4338            "Definition list item should not be merged into previous line. Got: {result:?}"
4339        );
4340        let lines: Vec<&str> = result.lines().collect();
4341        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4342        assert_eq!(lines[0], "Term");
4343        assert_eq!(lines[1], ": Definition of the term");
4344    }
4345
4346    #[test]
4347    fn test_is_paragraph_boundary() {
4348        // Core block boundary checks are inherited
4349        assert!(is_paragraph_boundary("# Heading", "# Heading"));
4350        assert!(is_paragraph_boundary("- item", "- item"));
4351        assert!(is_paragraph_boundary(":::", ":::"));
4352        assert!(is_paragraph_boundary(": definition", ": definition"));
4353
4354        // Indented code blocks (≥4 spaces or tab)
4355        assert!(is_paragraph_boundary("code", "    code"));
4356        assert!(is_paragraph_boundary("code", "\tcode"));
4357
4358        // Table rows via is_potential_table_row
4359        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4360        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
4361
4362        // Not paragraph boundaries
4363        assert!(!is_paragraph_boundary("regular text", "regular text"));
4364        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
4365    }
4366
4367    #[test]
4368    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4369        // Verifies that div markers (:::) are treated as paragraph boundaries
4370        // in reflow_paragraph_at_line, preventing reflow across div boundaries
4371        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4372        // Line 3 is the div marker — should not be reflowed
4373        let result = reflow_paragraph_at_line(content, 3, 80);
4374        assert!(result.is_none(), "Div marker line should not be reflowed");
4375    }
4376
4377    #[test]
4378    fn starts_block_construct_detects_block_openers() {
4379        // Bullet list markers: marker char followed by space or end
4380        for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4381            assert!(starts_block_construct(case), "bullet: {case:?}");
4382        }
4383        // Ordered list markers: up to 9 digits, `.` or `)`, then space or end
4384        for case in ["1. item", "1) item", "9. x", "123456789. x", "1.", "42) x"] {
4385            assert!(starts_block_construct(case), "ordered: {case:?}");
4386        }
4387        // Blockquote: `>` needs no following space
4388        for case in ["> quote", ">quote", ">"] {
4389            assert!(starts_block_construct(case), "blockquote: {case:?}");
4390        }
4391        // ATX headings: 1-6 hashes then space or end
4392        for case in ["# heading", "###### h6", "#", "##"] {
4393            assert!(starts_block_construct(case), "heading: {case:?}");
4394        }
4395        // Code fences: 3+ backticks or tildes
4396        for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4397            assert!(starts_block_construct(case), "fence: {case:?}");
4398        }
4399        // Setext underlines and thematic breaks
4400        for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4401            assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4402        }
4403        // Footnote and link-reference definitions: hoisting one to line start
4404        // reclassifies it and can resolve dangling references elsewhere
4405        for case in [
4406            "[^1]: text",
4407            "[^note]:",
4408            "[ref]: http://example.com",
4409            "[wat]: url follows",
4410        ] {
4411            assert!(starts_block_construct(case), "definition: {case:?}");
4412        }
4413        // Block-level HTML tags (rumdl parser's HTML block classification)
4414        for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4415            assert!(starts_block_construct(case), "html block: {case:?}");
4416        }
4417    }
4418
4419    #[test]
4420    fn starts_block_construct_allows_ordinary_prose() {
4421        for case in [
4422            "",
4423            "word",
4424            "-5 degrees",
4425            "--flag",
4426            "-item",
4427            "#hashtag",
4428            "####### seven hashes is not a heading",
4429            "1.5 million",
4430            "1234567890. ten digits is not a list marker",
4431            "1:30 pm",
4432            "*emphasis*",
4433            "**bold** text",
4434            "__bold__ text",
4435            "_emphasis_ text",
4436            "`code` span",
4437            "`` double backtick span ``",
4438            "~~strikethrough~~",
4439            "=x",
4440            "== ==",
4441            "(parenthetical)",
4442            "[link](url)",
4443            "[text][ref] more",
4444            "[bracketed] aside",
4445            "[a](b) [ref]: first bracket is a link, not a label",
4446            "[esc\\]: not a close] text",
4447            "<span>inline</span>",
4448            "<b>bold</b>",
4449            "<https://example.com> autolink",
4450            "<mailto:a@b.com>",
4451            "<notarealtag>",
4452        ] {
4453            assert!(!starts_block_construct(case), "prose: {case:?}");
4454        }
4455    }
4456
4457    #[test]
4458    fn merge_block_construct_continuations_merges_marker_led_lines() {
4459        let lines = vec![
4460            "First sentence?".to_string(),
4461            "- looks like a list item".to_string(),
4462            "Second sentence.".to_string(),
4463        ];
4464        assert_eq!(
4465            merge_block_construct_continuations(lines),
4466            vec![
4467                "First sentence? - looks like a list item".to_string(),
4468                "Second sentence.".to_string(),
4469            ]
4470        );
4471
4472        // The first line keeps its position: it replaces the paragraph's
4473        // original start, where the source already established the context.
4474        let lines = vec!["- real list content".to_string(), "continuation".to_string()];
4475        assert_eq!(
4476            merge_block_construct_continuations(lines.clone()),
4477            lines,
4478            "first line must never be merged"
4479        );
4480    }
4481
4482    #[test]
4483    fn wrap_never_starts_a_line_with_a_block_marker() {
4484        let options = ReflowOptions {
4485            line_length: 25,
4486            ..Default::default()
4487        };
4488        // The dash lands exactly at the wrap point; the wrapper must break one
4489        // word earlier so the dash stays mid-line.
4490        let lines = reflow_line(
4491            "Some words here and then - a dash clause that wraps around the limit.",
4492            &options,
4493        );
4494        assert_eq!(
4495            lines,
4496            vec![
4497                "Some words here and",
4498                "then - a dash clause that",
4499                "wraps around the limit."
4500            ]
4501        );
4502
4503        // Every marker category must stay mid-line in wrap mode, whatever the width.
4504        for input in [
4505            "Alpha beta gamma delta epsilon - dash clause here to wrap",
4506            "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
4507            "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
4508            "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
4509            "Alpha beta gamma delta epsilon * star clause here to wrap",
4510            "Alpha beta gamma delta epsilon + plus clause here to wrap",
4511        ] {
4512            for width in 10..40 {
4513                let options = ReflowOptions {
4514                    line_length: width,
4515                    ..Default::default()
4516                };
4517                for line in reflow_line(input, &options) {
4518                    assert!(
4519                        !starts_block_construct(&line),
4520                        "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
4521                    );
4522                }
4523            }
4524        }
4525    }
4526
4527    #[test]
4528    fn sentence_per_line_keeps_block_markers_mid_line() {
4529        let options = ReflowOptions {
4530            line_length: 80,
4531            sentence_per_line: true,
4532            ..Default::default()
4533        };
4534        // A sentence "starting" with a dash must stay attached to the previous
4535        // sentence instead of becoming a list item (issue #728).
4536        let lines = reflow_line(
4537            "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
4538            &options,
4539        );
4540        assert_eq!(
4541            lines,
4542            vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
4543        );
4544
4545        // Same for heading, blockquote, and ordered-list lookalikes.
4546        let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
4547        assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
4548
4549        let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
4550        assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
4551
4552        let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
4553        for line in &lines {
4554            assert!(
4555                !starts_block_construct(line),
4556                "sentence-per-line output opens a block construct: {line:?}"
4557            );
4558        }
4559    }
4560
4561    #[test]
4562    fn inline_math_directly_after_display_math_stays_atomic() {
4563        // The inline-math regex's lookbehind `(?<!\$)` is slice-start-sensitive:
4564        // a search anchored at the cursor accepts a `$` whose real predecessor
4565        // is a `$` (the lookbehind sees nothing before the slice), while a
4566        // cached search anchored earlier sees the `$` and rejects it. After
4567        // display math consumes `$$a$$`, the cursor sits directly after a `$`;
4568        // the match cache must re-search there or `$bb cc dd$` degrades to
4569        // plain text and gets wrapped apart, breaking math rendering.
4570        let options = ReflowOptions {
4571            line_length: 8,
4572            ..Default::default()
4573        };
4574        let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
4575        assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
4576    }
4577
4578    #[test]
4579    fn test_code_span_parsing() {
4580        // 1. Single backtick
4581        let elements = parse_markdown_elements_inner("`code`", false, false, None);
4582        assert_eq!(elements.len(), 1);
4583        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
4584
4585        // 2. Double backtick
4586        let elements = parse_markdown_elements_inner("``code``", false, false, None);
4587        assert_eq!(elements.len(), 1);
4588        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
4589
4590        // 3. Double backtick with single backtick inside
4591        let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
4592        assert_eq!(elements.len(), 1);
4593        assert!(
4594            matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
4595        );
4596
4597        // 4. Spaces inside
4598        let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
4599        assert_eq!(elements.len(), 1);
4600        assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
4601
4602        // 5. Unclosed backtick (should be parsed as Text)
4603        let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
4604        assert_eq!(elements.len(), 1);
4605        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
4606
4607        // 6. Unclosed backtick followed by a link (the link should be parsed as Link, not Text)
4608        let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
4609        // We expect: Text("`unclosed "), Link("[link](url)")
4610        assert_eq!(elements.len(), 2);
4611        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
4612        assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
4613    }
4614
4615    #[test]
4616    fn test_reflow_performance_long_input() {
4617        // Generate a string with many distinct unclosed backtick runs to test worst-case performance.
4618        // E.g., "` `` ` `` ` ...`"
4619        let mut text = String::new();
4620        for i in 1..400 {
4621            let backticks = "`".repeat(i);
4622            text.push_str(&backticks);
4623            text.push(' ');
4624        }
4625
4626        let start = std::time::Instant::now();
4627        let elements = parse_markdown_elements_inner(&text, false, false, None);
4628        let duration = start.elapsed();
4629
4630        // Ensure it completes in under 100ms.
4631        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4632        assert!(!elements.is_empty());
4633    }
4634
4635    #[test]
4636    fn test_reflow_performance_display_math_heavy() {
4637        // Every consumed `$$a$$` leaves the cursor directly after a `$`. The
4638        // inline-math slice-start probe must run in place at the cursor; a
4639        // suffix rescan there makes this input quadratic (~9s in a debug
4640        // build for these 4000 spans).
4641        let text = "$$a$$".repeat(4000);
4642
4643        let start = std::time::Instant::now();
4644        let elements = parse_markdown_elements_inner(&text, false, false, None);
4645        let duration = start.elapsed();
4646
4647        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
4648        assert_eq!(elements.len(), 4000);
4649    }
4650
4651    #[test]
4652    fn inline_math_len_at_start_matches_regex_at_slice_start() {
4653        // Exhaustive parity with INLINE_MATH_REGEX over short `$`-soup
4654        // strings: the helper must equal "regex match starting at position 0"
4655        // exactly, since the regex's leading lookbehind is vacuous at a slice
4656        // start. Any drift silently changes which math spans stay atomic.
4657        let alphabet = ['$', 'a', ' '];
4658        let mut inputs: Vec<String> = vec![String::new()];
4659        let mut frontier: Vec<String> = vec![String::new()];
4660        for _ in 0..6 {
4661            let mut longer = Vec::new();
4662            for prefix in &frontier {
4663                for ch in alphabet {
4664                    let mut s = prefix.clone();
4665                    s.push(ch);
4666                    longer.push(s);
4667                }
4668            }
4669            inputs.extend(longer.iter().cloned());
4670            frontier = longer;
4671        }
4672        // Multi-byte content must count bytes, not characters.
4673        inputs.push("$αβ$x".to_string());
4674        inputs.push("$α$$".to_string());
4675
4676        for s in &inputs {
4677            let expected = INLINE_MATH_REGEX
4678                .find(s)
4679                .ok()
4680                .flatten()
4681                .filter(|m| m.start() == 0)
4682                .map(|m| m.end());
4683            assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
4684        }
4685    }
4686
4687    #[test]
4688    fn inline_math_probe_after_dollar_matches_uncached_parse() {
4689        // Expected element lists verified against the uncached parser (the
4690        // parent of the match-cache commit): when a consumed span leaves the
4691        // cursor directly after a `$`, the at-cursor probe must reproduce
4692        // exactly what rescanning the suffix used to find - both the hits
4693        // (the lookbehind is vacuous at the cursor) and the misses.
4694        let cases = [
4695            ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
4696            (
4697                "$$a$$$b$ $$a$$$b$",
4698                r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
4699            ),
4700            // Probe hit whose content is only whitespace.
4701            (
4702                "$$a$$$ x $y z$",
4703                r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
4704            ),
4705            // Probe miss: `$$` after the cursor is not inline math.
4706            ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
4707            ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
4708            // Probe miss: the trailing lookahead rejects `$c$$`.
4709            (
4710                "$a$$b$$c$$d$ tail",
4711                r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
4712            ),
4713        ];
4714        for (input, expected) in cases {
4715            let elements = parse_markdown_elements_inner(input, false, false, None);
4716            assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
4717        }
4718    }
4719
4720    #[test]
4721    fn test_atomic_spans() {
4722        // --- Emphasis Spans ---
4723        let text_emphasis = "hello **word1 word2**";
4724
4725        let options_disabled = ReflowOptions {
4726            line_length: 18,
4727            atomic_spans: true,
4728            ..Default::default()
4729        };
4730        let lines_disabled = reflow_line(text_emphasis, &options_disabled);
4731        assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
4732
4733        let options_enabled = ReflowOptions {
4734            line_length: 18,
4735            atomic_spans: false,
4736            ..Default::default()
4737        };
4738        let lines_enabled = reflow_line(text_emphasis, &options_enabled);
4739        assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
4740
4741        // --- Code Spans ---
4742        let text_code = "hello `word1 word2`";
4743
4744        let lines_code_disabled = reflow_line(text_code, &options_disabled);
4745        assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
4746
4747        let lines_code_enabled = reflow_line(text_code, &options_enabled);
4748        assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
4749
4750        // Test multiple backticks with space padding
4751        let text_code_padding = "hello `` `word1` `word2` ``";
4752        let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
4753        assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
4754    }
4755
4756    #[test]
4757    fn test_emphasis_containing_markers_is_not_split() {
4758        let options = ReflowOptions {
4759            line_length: 5,
4760            atomic_spans: false,
4761            ..Default::default()
4762        };
4763        // Emphasis containing internal markers (e.g. escaped asterisks) should not be split to avoid formatting corruption
4764        let lines = reflow_line(r#"*foo \*bar*"#, &options);
4765        assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
4766    }
4767
4768    /// The parsed shape of a markdown fragment, normalized the way wrapping is
4769    /// allowed to change it and no further: block/inline structure and
4770    /// code-span contents are compared exactly, while prose whitespace is
4771    /// collapsed, because a wrap only ever swaps a space for a newline.
4772    fn semantic_shape(markdown: &str) -> String {
4773        let mut options = Options::empty();
4774        options.insert(Options::ENABLE_STRIKETHROUGH);
4775        let mut out = String::new();
4776        let push_prose = |out: &mut String, text: &str| {
4777            for c in text.chars() {
4778                if c.is_whitespace() {
4779                    if !out.ends_with(char::is_whitespace) {
4780                        out.push(' ');
4781                    }
4782                } else {
4783                    out.push(c);
4784                }
4785            }
4786        };
4787        for event in Parser::new_ext(markdown, options) {
4788            match event {
4789                Event::Text(text) => push_prose(&mut out, &text),
4790                Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
4791                // Interior whitespace in a code span is literal: compare verbatim.
4792                Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
4793                Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
4794                Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
4795                other => out.push_str(&format!("{other:?}")),
4796            }
4797        }
4798        out.trim().to_string()
4799    }
4800
4801    #[test]
4802    fn test_wrapping_a_span_never_changes_what_it_parses_to() {
4803        // Breaking a span is only safe if the document still parses the same.
4804        // Cover both settings and several budgets so the break lands in a
4805        // different place in each run.
4806        let corpus = [
4807            "_This is a very, very, very, very, very long line with some `code` inside._",
4808            "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa lambda_",
4809            "**strong text with `code` and more words than fit on one single line**",
4810            "~~struck text with `code` and more words than fit on one single line~~",
4811            "_emphasis with **nested strong that is quite long** and trailing words_",
4812            // Doubly nested spans: the whole content of the outer span is one
4813            // nested span, so there is no prose outside it to break at.
4814            "***A doubly nested bold italic span with more words than fit on a line***",
4815            "___Another doubly nested span with more words than fit on a single line___",
4816            "**_mixed strong then emphasis with more words than fit on a single line_**",
4817            "*__mixed emphasis then strong with more words than fit on a single line__*",
4818            "**~~strong strikethrough with more words than fit on a single line here~~**",
4819            // A marker that belongs to no well-formed span. Breaking at these
4820            // spaces would start a line with `* `, making it a list item.
4821            "**a * b with a stray marker and plenty more words to pass the budget**",
4822            "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
4823            "text before _a long emphasis with `code` inside of it here_ and after",
4824            "(_a parenthesized long emphasis with `code` inside of it right here_)",
4825            r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
4826            "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
4827            // A link nested in the span: its destination and title are not prose
4828            // and cannot absorb a line break.
4829            "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
4830            "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
4831            r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
4832            "_A [link with a long label](https://example.com/path) and `code` here._",
4833            "_An image ![alt text here](<a b c d e f g h>) plus `code` and more text_",
4834        ];
4835        for text in corpus {
4836            let expected = semantic_shape(text);
4837            for line_length in [20, 30, 40, 80] {
4838                for atomic_spans in [true, false] {
4839                    let options = ReflowOptions {
4840                        line_length,
4841                        atomic_spans,
4842                        ..Default::default()
4843                    };
4844                    let wrapped = reflow_line(text, &options).join("\n");
4845                    assert_eq!(
4846                        semantic_shape(&wrapped),
4847                        expected,
4848                        "reflow changed the parse of {text:?} at line_length={line_length} \
4849                         atomic_spans={atomic_spans}\n  wrapped: {wrapped:?}"
4850                    );
4851                }
4852            }
4853        }
4854    }
4855
4856    #[test]
4857    fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
4858        // Wiki links, Hugo shortcodes and math are atomic elements at the top
4859        // level but are invisible to the CommonMark parser, so `semantic_shape`
4860        // cannot catch a break inside one. Assert directly that they survive.
4861        let cases = [
4862            (
4863                "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
4864                "[[a wiki link]]",
4865            ),
4866            (
4867                "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
4868                "{{< foo bar >}}",
4869            ),
4870            ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
4871            ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
4872        ];
4873        for (text, construct) in cases {
4874            for line_length in [12, 20, 30] {
4875                for atomic_spans in [true, false] {
4876                    let options = ReflowOptions {
4877                        line_length,
4878                        atomic_spans,
4879                        ..Default::default()
4880                    };
4881                    let wrapped = reflow_line(text, &options).join("\n");
4882                    assert!(
4883                        wrapped.contains(construct),
4884                        "{construct} was broken at line_length={line_length} \
4885                         atomic_spans={atomic_spans}: {wrapped:?}"
4886                    );
4887                }
4888            }
4889        }
4890    }
4891
4892    #[test]
4893    fn test_overlong_emphasis_with_nested_code_span_wraps() {
4894        // An emphasis span longer than the whole line budget must still wrap,
4895        // even when it contains a nested code span: keeping it atomic would
4896        // leave a line that can never fit.
4897        let options = ReflowOptions {
4898            line_length: 80,
4899            atomic_spans: true,
4900            ..Default::default()
4901        };
4902        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
4903        let lines = reflow_line(text, &options);
4904        assert_eq!(
4905            lines,
4906            vec![
4907                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4908                "characters with some `code` inside._",
4909            ]
4910        );
4911    }
4912
4913    #[test]
4914    fn test_overlong_emphasis_with_nested_strong_wraps() {
4915        // Same for a nested strong span. The nested span itself stays whole.
4916        let options = ReflowOptions {
4917            line_length: 80,
4918            atomic_spans: true,
4919            ..Default::default()
4920        };
4921        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
4922        let lines = reflow_line(text, &options);
4923        assert_eq!(
4924            lines,
4925            vec![
4926                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
4927                "characters with some **bold** inside._",
4928            ]
4929        );
4930    }
4931
4932    #[test]
4933    fn test_overlong_doubly_nested_span_wraps() {
4934        // The whole content of the outer span is a single nested emphasis span.
4935        // Holding a nested span whole regardless of length left no break point
4936        // anywhere inside, so the line could never be wrapped and MD013 reported
4937        // a violation its own fixer refused to touch.
4938        let options = ReflowOptions {
4939            line_length: 80,
4940            atomic_spans: true,
4941            ..Default::default()
4942        };
4943        let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
4944        for (open, close) in [
4945            ("***", "***"),
4946            ("___", "___"),
4947            ("**_", "_**"),
4948            ("*__", "__*"),
4949            ("**~~", "~~**"),
4950        ] {
4951            let text = format!("{open}{body}{close}");
4952            assert!(text.len() > options.line_length, "case must start over budget");
4953            let lines = reflow_line(&text, &options);
4954            assert!(
4955                lines.len() > 1,
4956                "{open}...{close} should wrap but stayed on one line: {lines:?}"
4957            );
4958            assert!(
4959                lines.iter().all(|line| line.len() <= options.line_length),
4960                "{open}...{close} left a line over the budget: {lines:?}"
4961            );
4962            assert_eq!(
4963                lines.join(" "),
4964                text,
4965                "{open}...{close} wrapping must only replace a space with a newline"
4966            );
4967        }
4968    }
4969
4970    #[test]
4971    fn test_overlong_span_with_stray_marker_stays_whole() {
4972        // A `*` that belongs to no well-formed span means the content is not
4973        // fully modelled. Breaking at these spaces would put `* ` at the start
4974        // of a line, turning literal text into a list item.
4975        let options = ReflowOptions {
4976            line_length: 40,
4977            atomic_spans: true,
4978            ..Default::default()
4979        };
4980        let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
4981        let lines = reflow_line(text, &options);
4982        assert_eq!(lines, vec![text], "stray marker must keep the span whole");
4983    }
4984
4985    #[test]
4986    fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
4987        // A reference-style link only looks like a link once the document's
4988        // definitions are in scope, so the span's own parse sees plain text and
4989        // used to break inside the label. The top level holds these atomic, and
4990        // an inner span has to agree or `fmt` splits a link in one context and
4991        // not the other.
4992        let options = ReflowOptions {
4993            line_length: 30,
4994            atomic_spans: true,
4995            defined_references: Some(HashSet::from([
4996                "ref".to_string(),
4997                // A bare `[text]` is a link only when its own label is defined.
4998                "one two three four five six seven".to_string(),
4999            ])),
5000            ..Default::default()
5001        };
5002        for (text, link) in [
5003            (
5004                "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5005                "[one two three four five six seven][ref]",
5006            ),
5007            (
5008                "**alpha [one two three four five six seven][ref] beta gamma delta**",
5009                "[one two three four five six seven][ref]",
5010            ),
5011            (
5012                "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5013                "![one two three four five six seven][ref]",
5014            ),
5015            (
5016                "_**alpha [one two three four five six seven][] beta gamma delta**_",
5017                "[one two three four five six seven][]",
5018            ),
5019            (
5020                "_**alpha [one two three four five six seven] beta gamma delta**_",
5021                "[one two three four five six seven]",
5022            ),
5023        ] {
5024            let lines = reflow_line(text, &options);
5025            assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5026            assert!(
5027                lines.iter().any(|line| line.contains(link)),
5028                "{link} must stay on one line: {lines:?}"
5029            );
5030            assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5031        }
5032    }
5033
5034    #[test]
5035    fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5036        // A bare `[text]` is only a link when its label is defined. With the
5037        // definitions in scope and no match, it is literal prose and breaks like
5038        // any other words, exactly as the top level treats it.
5039        let options = ReflowOptions {
5040            line_length: 30,
5041            atomic_spans: true,
5042            defined_references: Some(HashSet::new()),
5043            ..Default::default()
5044        };
5045        let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5046        let lines = reflow_line(text, &options);
5047        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5048        assert!(
5049            !lines
5050                .iter()
5051                .any(|line| line.contains("[one two three four five six seven]")),
5052            "an undefined shortcut is prose and should break: {lines:?}"
5053        );
5054        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5055    }
5056
5057    #[test]
5058    fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5059        // A MkDocs/kramdown attr list carries structural interior whitespace, so
5060        // splitting it rewrites the attributes. The top level holds it whole; an
5061        // inner span has to agree. Only when the flavor is enabled.
5062        let attr = "{.highlight key=\"a b c\"}";
5063        let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5064        let options = ReflowOptions {
5065            line_length: 20,
5066            atomic_spans: true,
5067            attr_lists: true,
5068            ..Default::default()
5069        };
5070        let lines = reflow_line(&text, &options);
5071        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5072        assert!(
5073            lines.iter().any(|line| line.contains(attr)),
5074            "attr list must stay on one line: {lines:?}"
5075        );
5076        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5077
5078        // With the flavor off, the same braces are literal prose and break like
5079        // any other words, exactly as the top level treats them.
5080        let plain = ReflowOptions {
5081            attr_lists: false,
5082            ..options
5083        };
5084        let lines = reflow_line(&text, &plain);
5085        assert!(
5086            !lines.iter().any(|line| line.contains(attr)),
5087            "without the flavor the braces are prose and should break: {lines:?}"
5088        );
5089        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5090    }
5091
5092    #[test]
5093    fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5094        // Interior whitespace in a code span is literal, so a break inside one
5095        // would rewrite the code. The nested span is a single unbreakable unit
5096        // and its interior survives byte-for-byte.
5097        let options = ReflowOptions {
5098            line_length: 30,
5099            atomic_spans: true,
5100            ..Default::default()
5101        };
5102        let text = "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa_";
5103        let lines = reflow_line(text, &options);
5104        assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5105        assert!(
5106            lines.iter().any(|line| line.contains("`a  b`")),
5107            "nested code span must stay whole with its interior spaces: {lines:?}"
5108        );
5109        for line in &lines {
5110            assert_eq!(
5111                line.matches('`').count() % 2,
5112                0,
5113                "no line may contain half a code span: {line:?}"
5114            );
5115        }
5116    }
5117
5118    #[test]
5119    fn test_definition_list_marker_does_not_start_line() {
5120        let options = ReflowOptions {
5121            line_length: 20,
5122            ..Default::default()
5123        };
5124        // Wrap should not start a line with ": "
5125        let lines = reflow_line("This is a term and : definition here.", &options);
5126        for line in &lines {
5127            assert!(
5128                !line.trim_start().starts_with(": "),
5129                "Wrapped line should not start with definition marker: {line}"
5130            );
5131        }
5132    }
5133
5134    #[test]
5135    fn test_div_marker_does_not_start_line() {
5136        let options = ReflowOptions {
5137            line_length: 20,
5138            ..Default::default()
5139        };
5140        // Wrap should not start a line with ":::"
5141        let lines = reflow_line("This is some text with ::: class marker.", &options);
5142        for line in &lines {
5143            assert!(
5144                !line.trim_start().starts_with(":::"),
5145                "Wrapped line should not start with div marker: {line}"
5146            );
5147        }
5148    }
5149}