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