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    full: usize,
2586    /// Columns the link/image URL exemption forgives, zero when it is off or
2587    /// does not apply to this element.
2588    link_saving: usize,
2589    /// Columns the code-span exemption forgives, on the same terms.
2590    code_saving: usize,
2591    /// Whether this element is hard atomic (cannot be broken even as fallback)
2592    is_hard: bool,
2593}
2594
2595impl ElementSpan {
2596    /// A span covering `len` bytes from `start`, for an element whose full
2597    /// width is `full` and whose exempt widths are `width`.
2598    fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2599        Self {
2600            start,
2601            end: start + len,
2602            full,
2603            link_saving: full - width.link_exempt,
2604            code_saving: full - width.code_exempt,
2605            is_hard,
2606        }
2607    }
2608
2609    fn contains(&self, pos: usize) -> bool {
2610        pos > self.start && pos < self.end
2611    }
2612
2613    fn within(&self, start: usize, end: usize) -> bool {
2614        self.start >= start && self.end <= end
2615    }
2616
2617    fn exempt_width(&self) -> LineWidth {
2618        LineWidth {
2619            link_exempt: self.full - self.link_saving,
2620            code_exempt: self.full - self.code_saving,
2621        }
2622    }
2623}
2624
2625/// Compute element spans for a flat text representation of elements.
2626///
2627/// The offsets are byte positions, so they are always measured in
2628/// [`ReflowLengthMode::Bytes`] regardless of how lines are measured; only the
2629/// savings depend on `mode` and the active exemptions.
2630fn compute_element_spans(
2631    elements: &[Element],
2632    mode: ReflowLengthMode,
2633    exemptions: LengthExemptions,
2634) -> Vec<ElementSpan> {
2635    let mut spans = Vec::new();
2636    let mut offset = 0;
2637    for element in elements {
2638        let len = element.display_len(ReflowLengthMode::Bytes);
2639        if !matches!(element, Element::Text(_)) {
2640            let full = element.display_len(mode);
2641            let width = element.exempt_width(mode, exemptions);
2642            let is_hard = match element {
2643                Element::Bold { content, .. }
2644                | Element::Italic { content, .. }
2645                | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2646                _ => true,
2647            };
2648            spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2649        }
2650        offset += len;
2651    }
2652    spans
2653}
2654
2655/// Width of `text`, which sits at `[offset, offset + text.len())` of the line the
2656/// spans were computed for, under each exemption separately.
2657///
2658/// Only elements lying wholly inside the range are discounted. A split never
2659/// lands inside an element, so a partially covered element means the caller is
2660/// measuring something that is not a candidate line, and charging it in full is
2661/// the safe reading.
2662fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2663    let full = display_len(text, mode);
2664    let end = offset + text.len();
2665    let mut width = LineWidth::plain(full);
2666    for span in spans.iter().filter(|span| span.within(offset, end)) {
2667        width.link_exempt -= span.link_saving;
2668        width.code_exempt -= span.code_saving;
2669    }
2670    width
2671}
2672
2673/// Width of a standalone line under each exemption.
2674///
2675/// Callers that already hold the line's element spans should use [`measure`];
2676/// this is for the sites that see only the finished line and so have to parse it.
2677fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2678    let raw = display_len(line, options.length_mode);
2679    if !options.length_exemptions.any() {
2680        return LineWidth::plain(raw);
2681    }
2682    let elements = parse_markdown_elements_inner(
2683        line,
2684        options.attr_lists,
2685        options.myst_roles,
2686        options.defined_references.as_ref(),
2687    );
2688    let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2689    measure(line, 0, &spans, options.length_mode)
2690}
2691
2692/// Width of a standalone line as the checker measures it.
2693fn line_width(line: &str, options: &ReflowOptions) -> usize {
2694    line_width_components(line, options).effective()
2695}
2696
2697/// Whether a standalone line fits the budget as the checker measures it.
2698///
2699/// A saving is never negative, so the exempt width never exceeds the raw width:
2700/// a line that already fits as written fits under any exemption too, and needs
2701/// no parse. That keeps the parse off the path most lines take.
2702fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2703    display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2704}
2705
2706/// The non-Text element span that strictly contains `pos`, if any.
2707fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2708    spans.iter().copied().find(|span| span.contains(pos))
2709}
2710
2711/// Check if a byte position falls inside any non-Text element span
2712fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2713    element_containing(pos, spans).is_some()
2714}
2715
2716/// Minimum fraction of line_length that the first part of a split must occupy.
2717/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
2718const MIN_SPLIT_RATIO: f64 = 0.3;
2719
2720/// Split a line at the latest clause punctuation that keeps the first part
2721/// within `line_length`. Returns None if no valid split point exists or if
2722/// the split would create an unreasonably short first line.
2723fn split_at_clause_punctuation(
2724    text: &str,
2725    line_length: usize,
2726    element_spans: &[ElementSpan],
2727    length_mode: ReflowLengthMode,
2728) -> Option<(String, String)> {
2729    let chars: Vec<char> = text.chars().collect();
2730    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2731
2732    // Find the char index where accumulated display width exceeds line_length.
2733    // An element the checker discounts is charged its reduced width and stepped
2734    // over whole, so the search window reaches as far as the exempt measure of a
2735    // prefix allows; scanning char by char through a discounted URL would stop
2736    // short of break points that are legal under it.
2737    let mut width_acc = LineWidth::default();
2738    let mut search_end_char = 0;
2739    let mut byte = 0usize;
2740    let mut idx = 0usize;
2741    while idx < chars.len() {
2742        let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2743            Some(span) => {
2744                let source = &text[span.start..span.end];
2745                (
2746                    source.chars().count(),
2747                    source.len(),
2748                    measure(source, span.start, element_spans, length_mode),
2749                )
2750            }
2751            None => {
2752                let c = chars[idx];
2753                (
2754                    1,
2755                    c.len_utf8(),
2756                    LineWidth::plain(display_len(&c.to_string(), length_mode)),
2757                )
2758            }
2759        };
2760        if !(width_acc + width).fits(line_length) {
2761            break;
2762        }
2763        width_acc += width;
2764        byte += advance_bytes;
2765        idx += advance_chars;
2766        search_end_char = idx;
2767    }
2768
2769    // Scan backwards tracking parenthesis depth to skip clause punctuation
2770    // inside plain-text parenthetical groups.  Scanning right-to-left means
2771    // ')' opens a depth level and '(' closes it.  Parens that belong to a
2772    // markdown element are excluded using the char's start byte (not byte-after)
2773    // so that closing element delimiters at the span boundary are correctly
2774    // treated as part of the element.
2775    let mut paren_depth: i32 = 0;
2776    let mut best_pos = None;
2777    for i in (0..search_end_char).rev() {
2778        // Start byte of char i (for paren element check)
2779        let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2780        // Byte just after char i (for clause punctuation element check — existing convention)
2781        let byte_after: usize = byte_start + chars[i].len_utf8();
2782
2783        if !is_inside_element(byte_start, element_spans) {
2784            match chars[i] {
2785                ')' => paren_depth += 1,
2786                '(' => paren_depth = paren_depth.saturating_sub(1),
2787                _ => {}
2788            }
2789        }
2790
2791        if paren_depth == 0
2792            && is_clause_punctuation(chars[i])
2793            && clause_break_allowed_after(&chars, i)
2794            && !is_inside_element(byte_after, element_spans)
2795        {
2796            best_pos = Some(i);
2797            break;
2798        }
2799    }
2800
2801    let pos = best_pos?;
2802
2803    // Reject splits that create very short first lines
2804    let first: String = chars[..=pos].iter().collect();
2805    if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2806        return None;
2807    }
2808
2809    // Split after the punctuation character
2810    let rest: String = chars[pos + 1..].iter().collect();
2811    let rest = rest.trim_start().to_string();
2812
2813    if rest.is_empty() {
2814        return None;
2815    }
2816
2817    Some((first, rest))
2818}
2819
2820/// Compute plain-text paren-depth at each byte offset in `text`.
2821///
2822/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
2823/// nesting depth at byte `i` — counting only `(` and `)` that fall
2824/// outside markdown element spans.  This lets callers quickly check
2825/// whether a byte position lies inside a plain-text parenthetical group.
2826fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
2827    let mut map = vec![0i32; text.len()];
2828    let mut depth = 0i32;
2829    for (byte, c) in text.char_indices() {
2830        if !is_inside_element(byte, element_spans) {
2831            match c {
2832                '(' => depth += 1,
2833                ')' => depth = depth.saturating_sub(1),
2834                _ => {}
2835            }
2836        }
2837        // Fill the depth value for every byte of this (possibly multi-byte) char.
2838        let end = (byte + c.len_utf8()).min(map.len());
2839        for slot in &mut map[byte..end] {
2840            *slot = depth;
2841        }
2842    }
2843    map
2844}
2845
2846/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
2847/// group — i.e. it starts with `(`, ends with `)` (possibly followed by the
2848/// punctuation `split_at_parenthetical` attaches to it), has balanced parens
2849/// throughout, and the inner content contains at least one space (matching the
2850/// ≥2-word threshold used by `split_at_parenthetical` when deciding to split).
2851///
2852/// Used to prevent the short-line merge step from collapsing intentional
2853/// parenthetical splits back into the previous line.
2854fn is_standalone_parenthetical(line: &str) -> bool {
2855    let trimmed = line.trim();
2856    if !trimmed.starts_with('(') {
2857        return false;
2858    }
2859    // Strip the attached tail to find the real end: everything after the last
2860    // ')' belongs to the group only when no whitespace separates it.
2861    let Some(close) = trimmed.rfind(')') else {
2862        return false;
2863    };
2864    if trimmed[close + 1..].contains(char::is_whitespace) {
2865        return false;
2866    }
2867    let core = &trimmed[..=close];
2868    // Inner content must span multiple words (same threshold as split_at_parenthetical).
2869    let inner = &core[1..core.len() - 1];
2870    if !inner.contains(' ') {
2871        return false;
2872    }
2873    // Verify the parens are balanced (depth returns to 0 at the last ')').
2874    let mut depth = 0i32;
2875    for c in core.chars() {
2876        match c {
2877            '(' => depth += 1,
2878            ')' => depth -= 1,
2879            _ => {}
2880        }
2881        if depth < 0 {
2882            return false;
2883        }
2884    }
2885    depth == 0
2886}
2887
2888/// Split a line before the latest break-word that keeps the first part
2889/// within `line_length`. Returns None if no valid split point exists or if
2890/// the split would create an unreasonably short first line.
2891fn split_at_break_word(
2892    text: &str,
2893    line_length: usize,
2894    element_spans: &[ElementSpan],
2895    length_mode: ReflowLengthMode,
2896) -> Option<(String, String)> {
2897    let lower = text.to_lowercase();
2898    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2899    let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
2900
2901    // Build a paren-depth map so we can skip break-words inside plain-text
2902    // parenthetical groups (matching the protection added to split_at_clause_punctuation).
2903    let depth_map = paren_depth_map(text, element_spans);
2904
2905    for &word in BREAK_WORDS {
2906        let mut search_start = 0;
2907        while let Some(pos) = lower[search_start..].find(word) {
2908            let abs_pos = search_start + pos;
2909
2910            // Verify it's a word boundary: preceded by space, followed by space
2911            let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
2912            let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
2913
2914            if preceded_by_space && followed_by_space {
2915                // The break goes BEFORE the word, so first part ends at abs_pos - 1
2916                let first_part = text[..abs_pos].trim_end();
2917                let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
2918
2919                // Skip break-words inside plain-text parenthetical groups.
2920                let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
2921
2922                if first_part_len >= min_first_len
2923                    && first_part_len <= line_length
2924                    && !is_inside_element(abs_pos, element_spans)
2925                    && !inside_paren
2926                {
2927                    // Prefer the latest valid split point
2928                    if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
2929                        best_split = Some((abs_pos, word.len()));
2930                    }
2931                }
2932            }
2933
2934            search_start = abs_pos + word.len();
2935        }
2936    }
2937
2938    let (byte_start, _word_len) = best_split?;
2939
2940    let first = text[..byte_start].trim_end().to_string();
2941    let rest = text[byte_start..].to_string();
2942
2943    if first.is_empty() || rest.trim().is_empty() {
2944        return None;
2945    }
2946
2947    Some((first, rest))
2948}
2949
2950/// Whether a proposed split takes the place of whitespace that `text` already has.
2951///
2952/// `first` is a prefix of `text` with its trailing whitespace removed and `rest`
2953/// a suffix with its leading whitespace removed, so the bytes between them are
2954/// exactly what the split consumed. That gap must be non-empty and hold nothing
2955/// but breakable whitespace the paragraph owns: the newline replacing it renders
2956/// as a single space, so an empty gap inserts a word boundary the author did not
2957/// write, and a gap holding anything else drops content. Whitespace inside an
2958/// inline element belongs to that element, where it is literal (a code span) or
2959/// structural (a link destination), never a place a line may break.
2960fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
2961    if !text.starts_with(first) || !text.ends_with(rest) {
2962        return false;
2963    }
2964    let gap_end = text.len() - rest.len();
2965    gap_end > first.len()
2966        && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
2967        && !element_spans
2968            .iter()
2969            .any(|span| first.len() < span.end && span.start < gap_end)
2970}
2971
2972/// Cascade-split a line that exceeds line_length.
2973/// Tries parenthetical boundaries, then clause punctuation, then break-words,
2974/// then word wrap.
2975///
2976/// This is iterative rather than recursive so a single very long line (tens of
2977/// thousands of words) cannot overflow the stack. Each accepted split shrinks
2978/// the remaining text by a non-empty prefix, so the loop always makes progress.
2979/// The whole line is parsed into markdown elements once up front; every
2980/// remaining suffix reuses those element spans (re-based to the suffix offset)
2981/// instead of re-parsing, which keeps repeated element parsing out of the loop.
2982fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
2983    let line_length = options.line_length;
2984    let length_mode = options.length_mode;
2985    let attr_lists = options.attr_lists;
2986    let myst_roles = options.myst_roles;
2987    let defined_references = options.defined_references.as_ref();
2988    if line_length == 0 || display_len(text, length_mode) <= line_length {
2989        return vec![text.to_string()];
2990    }
2991
2992    let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2993    let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
2994
2995    // The raw width is over budget, but an exemption may still bring the line
2996    // under it, in which case the checker accepts it as written.
2997    if measure(text, 0, &element_spans, length_mode).fits(line_length) {
2998        return vec![text.to_string()];
2999    }
3000
3001    // Element spans of the remaining suffix `text[start..]`, re-based so their
3002    // offsets are relative to the suffix. Split points never fall inside an
3003    // element, so every span lies wholly before or wholly at/after `start`.
3004    let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3005        if start == 0 {
3006            return element_spans.clone();
3007        }
3008        element_spans
3009            .iter()
3010            .filter(|span| span.end > start)
3011            .map(|span| ElementSpan {
3012                start: span.start.saturating_sub(start),
3013                end: span.end.saturating_sub(start),
3014                ..*span
3015            })
3016            .collect()
3017    };
3018
3019    let mut result = Vec::new();
3020    let mut start = 0usize;
3021
3022    loop {
3023        let remaining = &text[start..];
3024        let spans = rebased_spans(start);
3025        if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3026            result.push(remaining.to_string());
3027            return result;
3028        }
3029
3030        // `rest` is always a suffix of `remaining` (the splitters only trim its
3031        // leading whitespace), so `remaining.len() - rest.len()` is the number of
3032        // bytes consumed, and the new absolute offset is `start + consumed`.
3033        //
3034        // Every candidate must stand in for whitespace the text already has: a
3035        // line break renders as a space, so one placed between two characters
3036        // that were adjacent changes the rendered paragraph. A strategy that
3037        // proposes such a split is skipped and the next one gets a turn.
3038        let at_whitespace = |candidate: Option<(String, String)>| {
3039            candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3040        };
3041        let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3042            .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3043            .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3044
3045        if let Some((first, rest)) = split {
3046            let consumed = remaining.len().saturating_sub(rest.len());
3047            // Defensive: a zero-length advance would loop forever. Splitters only
3048            // return a non-empty `first`, so this never triggers, but guard anyway.
3049            if consumed == 0 {
3050                break;
3051            }
3052            result.push(first);
3053            start += consumed;
3054            continue;
3055        }
3056
3057        // No semantic split point: word-wrap the remaining suffix and finish.
3058        break;
3059    }
3060
3061    // Fallback: word wrap the still-oversized suffix using reflow_elements.
3062    let mut fallback_options = options.clone();
3063    fallback_options.break_on_sentences = false;
3064    fallback_options.preserve_breaks = false;
3065    fallback_options.sentence_per_line = false;
3066    fallback_options.semantic_line_breaks = false;
3067    fallback_options.require_sentence_capital = true;
3068    fallback_options.max_list_continuation_indent = None;
3069    fallback_options.defined_references = None;
3070    let remaining = &text[start..];
3071    let tail_elements = if start == 0 {
3072        elements
3073    } else {
3074        parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3075    };
3076    result.extend(reflow_elements(&tail_elements, &fallback_options));
3077    result
3078}
3079
3080/// Reflow elements using semantic line breaks strategy:
3081/// 1. Split at sentence boundaries (always)
3082/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
3083fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3084    // Step 1: Split into sentences using existing sentence-per-line logic
3085    let sentence_lines =
3086        reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
3087
3088    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
3089    // When line_length is 0 (unlimited), skip cascading — sentence splits only
3090    if options.line_length == 0 {
3091        return sentence_lines;
3092    }
3093
3094    let mut result = Vec::new();
3095    for line in sentence_lines {
3096        if line_fits(&line, options) {
3097            result.push(line);
3098        } else {
3099            result.extend(cascade_split_line(&line, options));
3100        }
3101    }
3102
3103    // Step 3: Merge very short trailing lines back into the previous line.
3104    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
3105    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3106    let mut merged: Vec<String> = Vec::with_capacity(result.len());
3107    for line in result {
3108        if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3109            // Don't merge a line that is itself a standalone parenthetical group —
3110            // it was placed on its own line intentionally by split_at_parenthetical.
3111            if is_standalone_parenthetical(&line) {
3112                merged.push(line);
3113                continue;
3114            }
3115
3116            // Don't merge across sentence boundaries — sentence splits are intentional
3117            let prev_ends_at_sentence = {
3118                let trimmed = merged.last().unwrap().trim_end();
3119                trimmed
3120                    .chars()
3121                    .rev()
3122                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3123                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3124            };
3125
3126            if !prev_ends_at_sentence {
3127                let prev = merged.last_mut().unwrap();
3128                let combined = format!("{prev} {line}");
3129                // Only merge if the combined line fits within the limit
3130                if line_fits(&combined, options) {
3131                    *prev = combined;
3132                    continue;
3133                }
3134            }
3135        }
3136        merged.push(line);
3137    }
3138    merged
3139}
3140
3141/// Find the last space in `line` that is safe to split at.
3142/// Safe spaces are those NOT inside rendered non-Text elements and whose
3143/// suffix would not open a block construct when placed at line start.
3144/// `element_spans` locates the non-Text elements in the line. Spans use
3145/// exclusive bounds (pos > start && pos < end) because element delimiters
3146/// (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never spaces, so only
3147/// interior positions need protection. The scan keeps looking left past
3148/// construct-leading suffixes (e.g. a trailing `- `), so a usable earlier break
3149/// point is found instead of forcing an overlong line.
3150fn rfind_safe_space(
3151    line: &str,
3152    element_spans: &[ElementSpan],
3153    options: &ReflowOptions,
3154    relax_soft_spans: bool,
3155) -> Option<usize> {
3156    line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3157        line.as_bytes()[pos] == b' '
3158            && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3159            && !starts_block_construct(&line[pos + 1..])
3160    })
3161}
3162
3163fn is_inside_element_filtered(
3164    pos: usize,
3165    spans: &[ElementSpan],
3166    options: &ReflowOptions,
3167    relax_soft_spans: bool,
3168) -> bool {
3169    spans.iter().any(|span| {
3170        span.contains(pos)
3171            && (!relax_soft_spans
3172                || span.is_hard
3173                || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3174    })
3175}
3176
3177/// A token that must not start a wrapped line, together with the width it
3178/// contributes and the separator that precedes it. The width travels with the
3179/// text because only the caller knows which construct produced it, and so which
3180/// exemption it earns.
3181#[derive(Clone, Copy)]
3182struct Attached<'a> {
3183    text: &'a str,
3184    width: LineWidth,
3185    separator: &'a str,
3186}
3187
3188/// Break `current_line` one word earlier so `attach` never starts a wrapped
3189/// line: everything before the line's last safe space is emitted as a
3190/// finished line, and the carried word plus the separator plus the attached
3191/// text becomes the new current line. The returned byte length of the carried
3192/// word lets callers re-record a span for the attached text. Returns `None`
3193/// (line untouched) when the line has no safe break point.
3194///
3195/// The new width is the carried text measured through the spans it came with,
3196/// plus the attached width, so an exemption the carried text or the attached
3197/// token earns is preserved across the break instead of being re-derived from a
3198/// bare string.
3199///
3200/// The carried text keeps the element spans that fell inside it, rebased to the
3201/// new line. Dropping them would leave a later break blind to an element the
3202/// carried text still holds, and so free to split a link or a code span down
3203/// the middle.
3204fn break_before_attached(
3205    lines: &mut Vec<String>,
3206    current_line: &mut String,
3207    current_width: &mut LineWidth,
3208    element_spans: &mut Vec<ElementSpan>,
3209    attach: Attached<'_>,
3210    options: &ReflowOptions,
3211) -> Option<usize> {
3212    let length_mode = options.length_mode;
3213    let last_space = rfind_safe_space(current_line, element_spans, options, false)
3214        .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3215    let before = current_line[..last_space]
3216        .trim_end_matches(is_breakable_whitespace)
3217        .to_string();
3218    let after = current_line[last_space + 1..].to_string();
3219    let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3220    lines.push(before);
3221    let carried = after.len();
3222    let Attached { text, width, separator } = attach;
3223    *current_line = format!("{after}{separator}{text}");
3224    *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3225    rebase_spans_after_break(element_spans, last_space + 1);
3226    Some(carried)
3227}
3228
3229/// Keep the spans that reach into the text starting at `carried_start` and move
3230/// them into that text's coordinates, discarding the ones that belong to the
3231/// line just emitted.
3232///
3233/// A span that starts before `carried_start` is clamped to 0 rather than
3234/// dropped. `rfind_safe_space` never breaks inside a span, so this cannot
3235/// normally happen; clamping keeps the whole prefix protected if it ever does,
3236/// where dropping the span would license a break inside an element.
3237fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3238    element_spans.retain(|span| span.end > carried_start);
3239    for span in element_spans.iter_mut() {
3240        span.start = span.start.saturating_sub(carried_start);
3241        span.end -= carried_start;
3242    }
3243}
3244
3245/// Reflow elements into lines that fit within the line length
3246fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3247    let mut lines = Vec::new();
3248    let mut current_line = String::new();
3249    // The line's width under each exemption the checker applies. With no
3250    // exemption active both components are the plain display width.
3251    let mut current_width = LineWidth::default();
3252    // Track byte spans of non-Text elements in current_line for safe splitting
3253    let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3254    let length_mode = options.length_mode;
3255    let exemptions = options.length_exemptions;
3256
3257    for (idx, element) in elements.iter().enumerate() {
3258        let element_len = element.display_len(length_mode);
3259        let element_width = element.exempt_width(length_mode, exemptions);
3260        let is_hard = match element {
3261            Element::Bold { content, .. }
3262            | Element::Italic { content, .. }
3263            | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3264            _ => true,
3265        };
3266
3267        // Determine adjacency from the original elements, not from current_line.
3268        // Elements are adjacent when there's no breakable whitespace between them
3269        // in the source (a non-breaking space stays inside the neighboring token,
3270        // so the pair must also stay attached):
3271        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
3272        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
3273        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
3274        // - Code("`x`") → Text("\u{00A0}:") = adjacent (only a non-breaking space between)
3275        let is_adjacent_to_prev = if idx > 0 {
3276            match (&elements[idx - 1], element) {
3277                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3278                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3279                _ => true,
3280            }
3281        } else {
3282            false
3283        };
3284
3285        // For text elements that might need breaking
3286        if let Element::Text(text) = element {
3287            // Check if original text had leading breakable whitespace
3288            let has_leading_space = text.starts_with(is_breakable_whitespace);
3289            // If this is a text element, always process it word by word
3290            let words: Vec<&str> = split_breakable_words(text).collect();
3291
3292            for (i, word) in words.iter().enumerate() {
3293                // A bare word carries no construct the checker exempts.
3294                let word_width = LineWidth::plain(display_len(word, length_mode));
3295                // A token that is only punctuation (optionally led by a
3296                // non-breaking space, e.g. French "\u{00A0}:") must never be
3297                // hoisted to the start of a line. Tokens are never empty
3298                // (`split_breakable_words` filters), so `all` cannot be
3299                // vacuously true.
3300                let is_trailing_punct = word.chars().all(|c| {
3301                    matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3302                });
3303
3304                // First word of text adjacent to preceding non-text element
3305                // must stay attached (e.g., shortcode followed by punctuation or text)
3306                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3307
3308                if is_first_adjacent {
3309                    // Attach directly without space, preventing line break
3310                    if !(current_width + word_width).fits(options.line_length)
3311                        && !current_width.is_empty()
3312                        && break_before_attached(
3313                            &mut lines,
3314                            &mut current_line,
3315                            &mut current_width,
3316                            &mut current_line_element_spans,
3317                            Attached {
3318                                text: word,
3319                                width: word_width,
3320                                separator: "",
3321                            },
3322                            options,
3323                        )
3324                        .is_some()
3325                    {
3326                        // Would exceed — broke before the adjacent group at the
3327                        // last safe space (element-aware, so links/code stay
3328                        // intact); with no safe break point the group is
3329                        // attached and the long line accepted.
3330                    } else {
3331                        current_line.push_str(word);
3332                        current_width += word_width;
3333                    }
3334                } else if !current_width.is_empty()
3335                    && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3336                {
3337                    if is_trailing_punct {
3338                        // The overflowing token is bare punctuation, which must
3339                        // not start a line. Break one word earlier so the mark
3340                        // travels with the word it follows ("… mot :"), keeping
3341                        // the source space (French double punctuation requires
3342                        // it); with no safe earlier break point, accept the
3343                        // overlong line rather than rewrite content.
3344                        if break_before_attached(
3345                            &mut lines,
3346                            &mut current_line,
3347                            &mut current_width,
3348                            &mut current_line_element_spans,
3349                            Attached {
3350                                text: word,
3351                                width: word_width,
3352                                separator: " ",
3353                            },
3354                            options,
3355                        )
3356                        .is_none()
3357                        {
3358                            current_line.push(' ');
3359                            current_line.push_str(word);
3360                            current_width += LineWidth::plain(1) + word_width;
3361                        }
3362                    } else if !starts_block_construct(word) {
3363                        // Start a new line
3364                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3365                        current_line = word.to_string();
3366                        current_width = word_width;
3367                        current_line_element_spans.clear();
3368                    } else if break_before_attached(
3369                        &mut lines,
3370                        &mut current_line,
3371                        &mut current_width,
3372                        &mut current_line_element_spans,
3373                        Attached {
3374                            text: word,
3375                            width: word_width,
3376                            separator: " ",
3377                        },
3378                        options,
3379                    )
3380                    .is_some()
3381                    {
3382                        // The overflowing word would open a block construct at line
3383                        // start. Broke one word earlier instead so the marker stays
3384                        // mid-line: "... and then" + "- clause" becomes "... and" +
3385                        // "then - clause".
3386                    } else {
3387                        // No safe earlier break point — keep the marker attached and
3388                        // accept the long line rather than corrupt the structure.
3389                        if i > 0 || has_leading_space {
3390                            current_line.push(' ');
3391                            current_width += LineWidth::plain(1);
3392                        }
3393                        current_line.push_str(word);
3394                        current_width += word_width;
3395                    }
3396                } else {
3397                    // Add a space wherever the source had breakable whitespace at
3398                    // this position. For the first word of a text run (i == 0)
3399                    // that means the run had a leading space — and reaching this
3400                    // branch already implies the word is not adjacent to the
3401                    // previous element, so the space is real. Later words
3402                    // (i > 0) always had whitespace before them: that is what
3403                    // separated them during tokenization. This holds for bare
3404                    // punctuation too ("ligne : la" keeps its French
3405                    // orthographic space): reflow moves line breaks, it does not
3406                    // rewrite characters. The no-space (adjacent) case is
3407                    // handled above by `is_first_adjacent`.
3408                    let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3409                    if add_space {
3410                        current_line.push(' ');
3411                        current_width += LineWidth::plain(1);
3412                    }
3413                    current_line.push_str(word);
3414                    current_width += word_width;
3415                }
3416            }
3417        } else {
3418            let span_info = match element {
3419                Element::Italic { content, underscore } => {
3420                    Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3421                }
3422                Element::Bold { content, underscore } => {
3423                    Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3424                }
3425                Element::Strikethrough { content, double } => {
3426                    Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3427                }
3428                Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3429                _ => None,
3430            };
3431
3432            // A span that alone exceeds the line budget is broken even when
3433            // spans are atomic, since keeping it whole would leave a line that can
3434            // never fit. `breakable_units` decides where that is safe.
3435            let breakable: Option<Vec<&str>> = match span_info {
3436                Some((content, _, is_code)) => {
3437                    if is_code {
3438                        (!options.atomic_spans && code_span_wraps_losslessly(content))
3439                            .then(|| split_breakable_words(content).collect())
3440                    } else {
3441                        (!options.atomic_spans || element_len > options.line_length)
3442                            .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3443                            .flatten()
3444                    }
3445                }
3446                None => None,
3447            };
3448
3449            if let Some(words) = breakable {
3450                let (_, marker, is_code) = span_info.expect("breakable implies a span");
3451                let n = words.len();
3452                if n == 0 {
3453                    // Empty span — treat as atomic
3454                    let full = format!("{marker}{marker}");
3455                    let full_width = LineWidth::plain(display_len(&full, length_mode));
3456                    if !is_adjacent_to_prev && !current_width.is_empty() {
3457                        current_line.push(' ');
3458                        current_width += LineWidth::plain(1);
3459                    }
3460                    current_line.push_str(&full);
3461                    current_width += full_width;
3462                } else {
3463                    for (i, word) in words.iter().enumerate() {
3464                        let is_first = i == 0;
3465                        let is_last = i == n - 1;
3466
3467                        let space_start = if is_first && is_code && word.starts_with('`') {
3468                            " "
3469                        } else {
3470                            ""
3471                        };
3472                        let space_end = if is_last && is_code && word.ends_with('`') {
3473                            " "
3474                        } else {
3475                            ""
3476                        };
3477
3478                        let word_str: String = match (is_first, is_last) {
3479                            (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3480                            (true, false) => format!("{marker}{space_start}{word}"),
3481                            (false, true) => format!("{word}{space_end}{marker}"),
3482                            (false, false) => word.to_string(),
3483                        };
3484                        let word_elements = parse_elements(&word_str, options);
3485                        let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3486                        let word_width = measure(&word_str, 0, &word_spans, length_mode);
3487
3488                        let needs_space = if is_first {
3489                            !is_adjacent_to_prev && !current_width.is_empty()
3490                        } else {
3491                            !current_width.is_empty()
3492                        };
3493
3494                        if needs_space
3495                            && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3496                            && !starts_block_construct(&word_str)
3497                        {
3498                            lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3499                            current_line = word_str;
3500                            current_width = word_width;
3501                            current_line_element_spans.clear();
3502                            for span in word_spans {
3503                                current_line_element_spans.push(span);
3504                            }
3505                        } else {
3506                            let mut start_pos = current_line.len();
3507                            if needs_space {
3508                                current_line.push(' ');
3509                                current_width += LineWidth::plain(1);
3510                                start_pos += 1;
3511                            }
3512                            current_line.push_str(&word_str);
3513                            current_width += word_width;
3514                            for mut span in word_spans {
3515                                span.start += start_pos;
3516                                span.end += start_pos;
3517                                current_line_element_spans.push(span);
3518                            }
3519                        }
3520                    }
3521                }
3522            } else {
3523                // For non-text elements (code, links, references), treat as atomic units
3524                // These should never be broken across lines
3525                let element_str = format!("{element}");
3526
3527                if is_adjacent_to_prev {
3528                    // Adjacent to preceding text — attach directly without space
3529                    if !(current_width + element_width).fits(options.line_length)
3530                        && let Some(carried) = break_before_attached(
3531                            &mut lines,
3532                            &mut current_line,
3533                            &mut current_width,
3534                            &mut current_line_element_spans,
3535                            Attached {
3536                                text: &element_str,
3537                                width: element_width,
3538                                separator: "",
3539                            },
3540                            options,
3541                        )
3542                    {
3543                        // Would exceed limit — broke before the adjacent word group
3544                        // at the last safe space (element-aware, so links/code stay
3545                        // intact). Record the element span in the new current_line.
3546                        current_line_element_spans.push(ElementSpan::new(
3547                            carried,
3548                            element_str.len(),
3549                            element_len,
3550                            element_width,
3551                            is_hard,
3552                        ));
3553                    } else {
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                            is_hard,
3563                        ));
3564                    }
3565                } else if !current_width.is_empty()
3566                    && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3567                {
3568                    if !starts_block_construct(&element_str) {
3569                        // Not adjacent, would exceed — start new line
3570                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3571                        current_line.clone_from(&element_str);
3572                        current_width = element_width;
3573                        current_line_element_spans.clear();
3574                        current_line_element_spans.push(ElementSpan::new(
3575                            0,
3576                            element_str.len(),
3577                            element_len,
3578                            element_width,
3579                            is_hard,
3580                        ));
3581                    } else if let Some(carried) = break_before_attached(
3582                        &mut lines,
3583                        &mut current_line,
3584                        &mut current_width,
3585                        &mut current_line_element_spans,
3586                        Attached {
3587                            text: &element_str,
3588                            width: element_width,
3589                            separator: " ",
3590                        },
3591                        options,
3592                    ) {
3593                        // The overflowing element would open a block construct at
3594                        // line start (e.g. an HtmlTag like `<div>`). Broke one word
3595                        // earlier instead so the element stays mid-line.
3596                        let start = carried + 1;
3597                        current_line_element_spans.push(ElementSpan::new(
3598                            start,
3599                            element_str.len(),
3600                            element_len,
3601                            element_width,
3602                            is_hard,
3603                        ));
3604                    } else {
3605                        // No safe earlier break point — keep the element attached
3606                        // and accept the long line rather than corrupt the structure.
3607                        let ends_with_opener =
3608                            current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3609                        if !ends_with_opener {
3610                            current_line.push(' ');
3611                            current_width += LineWidth::plain(1);
3612                        }
3613                        let start = current_line.len();
3614                        current_line.push_str(&element_str);
3615                        current_width += element_width;
3616                        current_line_element_spans.push(ElementSpan::new(
3617                            start,
3618                            element_str.len(),
3619                            element_len,
3620                            element_width,
3621                            is_hard,
3622                        ));
3623                    }
3624                } else {
3625                    // Not adjacent, fits — add with space
3626                    let ends_with_opener =
3627                        current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3628                    if !current_width.is_empty() && !ends_with_opener {
3629                        current_line.push(' ');
3630                        current_width += LineWidth::plain(1);
3631                    }
3632                    let start = current_line.len();
3633                    current_line.push_str(&element_str);
3634                    current_width += element_width;
3635                    current_line_element_spans.push(ElementSpan::new(
3636                        start,
3637                        element_str.len(),
3638                        element_len,
3639                        element_width,
3640                        is_hard,
3641                    ));
3642                }
3643            }
3644        }
3645    }
3646
3647    // Don't forget the last line
3648    if !current_line.is_empty() {
3649        lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3650    }
3651
3652    lines
3653}
3654
3655/// Reflow markdown content preserving structure
3656pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3657    let lines: Vec<&str> = content.lines().collect();
3658    let mut result = Vec::new();
3659    let mut i = 0;
3660
3661    while i < lines.len() {
3662        let line = lines[i];
3663        let trimmed = line.trim();
3664
3665        // Preserve empty lines
3666        if trimmed.is_empty() {
3667            result.push(String::new());
3668            i += 1;
3669            continue;
3670        }
3671
3672        // Preserve headings as-is
3673        if trimmed.starts_with('#') {
3674            result.push(line.to_string());
3675            i += 1;
3676            continue;
3677        }
3678
3679        // Preserve Quarto/Pandoc div markers (:::) as-is
3680        if trimmed.starts_with(":::") {
3681            result.push(line.to_string());
3682            i += 1;
3683            continue;
3684        }
3685
3686        // Preserve fenced code blocks
3687        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3688            result.push(line.to_string());
3689            i += 1;
3690            // Copy lines until closing fence
3691            while i < lines.len() {
3692                result.push(lines[i].to_string());
3693                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3694                    i += 1;
3695                    break;
3696                }
3697                i += 1;
3698            }
3699            continue;
3700        }
3701
3702        // Preserve indented code blocks (4+ columns accounting for tab expansion)
3703        if calculate_indentation_width_default(line) >= 4 {
3704            // Collect all consecutive indented lines
3705            result.push(line.to_string());
3706            i += 1;
3707            while i < lines.len() {
3708                let next_line = lines[i];
3709                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
3710                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3711                    result.push(next_line.to_string());
3712                    i += 1;
3713                } else {
3714                    break;
3715                }
3716            }
3717            continue;
3718        }
3719
3720        // Preserve block quotes (but reflow their content)
3721        if trimmed.starts_with('>') {
3722            // find() returns byte position which is correct for str slicing
3723            // The unwrap is safe because we already verified trimmed starts with '>'
3724            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3725            let quote_prefix = line[0..=gt_pos].to_string();
3726            let quote_content = &line[quote_prefix.len()..].trim_start();
3727
3728            let reflowed = reflow_line(quote_content, options);
3729            for reflowed_line in &reflowed {
3730                result.push(format!("{quote_prefix} {reflowed_line}"));
3731            }
3732            i += 1;
3733            continue;
3734        }
3735
3736        // Preserve horizontal rules first (before checking for lists)
3737        if is_horizontal_rule(trimmed) {
3738            result.push(line.to_string());
3739            i += 1;
3740            continue;
3741        }
3742
3743        // Preserve lists (but not horizontal rules)
3744        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3745            // Find the list marker and preserve indentation
3746            let indent = line.len() - line.trim_start().len();
3747            let indent_str = " ".repeat(indent);
3748
3749            // For numbered lists, find the period and the space after it
3750            // For bullet lists, find the marker and the space after it
3751            let mut marker_end = indent;
3752            let mut content_start = indent;
3753
3754            if trimmed.chars().next().is_some_and(char::is_numeric) {
3755                // Numbered list: find the period
3756                if let Some(period_pos) = line[indent..].find('.') {
3757                    marker_end = indent + period_pos + 1; // Include the period
3758                    content_start = marker_end;
3759                    // Skip any spaces after the period to find content start
3760                    // Use byte-based check since content_start is a byte index
3761                    // This is safe because space is ASCII (single byte)
3762                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3763                        content_start += 1;
3764                    }
3765                }
3766            } else {
3767                // Bullet list: marker is single character
3768                marker_end = indent + 1; // Just the marker character
3769                content_start = marker_end;
3770                // Skip any spaces after the marker
3771                // Use byte-based check since content_start is a byte index
3772                // This is safe because space is ASCII (single byte)
3773                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3774                    content_start += 1;
3775                }
3776            }
3777
3778            // Minimum indent for continuation lines (based on list marker, before checkbox)
3779            let min_continuation_indent = content_start;
3780
3781            // Detect checkbox/task list markers: [ ], [x], [X]
3782            // GFM task lists work with both unordered and ordered lists
3783            let rest = &line[content_start..];
3784            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3785                marker_end = content_start + 3; // Include the checkbox `[ ]`
3786                content_start += 4; // Skip past `[ ] `
3787            }
3788
3789            let marker = &line[indent..marker_end];
3790
3791            // Collect all content for this list item (including continuation lines)
3792            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
3793            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3794            i += 1;
3795
3796            // Collect continuation lines (indented lines that are part of this list item)
3797            // Use the base marker indent (not checkbox-extended) for collection,
3798            // since users may indent continuations to the bullet level, not the checkbox level
3799            while i < lines.len() {
3800                let next_line = lines[i];
3801                let next_trimmed = next_line.trim();
3802
3803                // Stop if we hit an empty line or another list item or special block
3804                if is_block_boundary(next_trimmed) {
3805                    break;
3806                }
3807
3808                // Check if this line is indented (continuation of list item)
3809                let next_indent = next_line.len() - next_line.trim_start().len();
3810                if next_indent >= min_continuation_indent {
3811                    // This is a continuation line - add its content
3812                    // Preserve hard breaks while trimming excessive whitespace
3813                    let trimmed_start = next_line.trim_start();
3814                    list_content.push(trim_preserving_hard_break(trimmed_start));
3815                    i += 1;
3816                } else {
3817                    // Not indented enough, not part of this list item
3818                    break;
3819                }
3820            }
3821
3822            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
3823            // Hard breaks should prevent joining with the next line
3824            let combined_content = if options.preserve_breaks {
3825                list_content[0].clone()
3826            } else {
3827                // Check if any lines have hard breaks - if so, preserve the structure
3828                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3829                if has_hard_breaks {
3830                    // Don't join lines with hard breaks - keep them separate with newlines
3831                    list_content.join("\n")
3832                } else {
3833                    // No hard breaks, safe to join with spaces
3834                    list_content.join(" ")
3835                }
3836            };
3837
3838            // Calculate the proper indentation for continuation lines
3839            let trimmed_marker = marker;
3840            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3841                // Cap the relative indent (past the nesting level) to max_indent,
3842                // then add back the nesting indent so nested items stay correct
3843                indent + (content_start - indent).min(max_indent)
3844            } else {
3845                content_start
3846            };
3847
3848            // Adjust line length to account for list marker and space
3849            let prefix_length = indent + trimmed_marker.len() + 1;
3850
3851            // Create adjusted options with reduced line length
3852            let adjusted_options = ReflowOptions {
3853                line_length: options.line_length.saturating_sub(prefix_length),
3854                ..options.clone()
3855            };
3856
3857            let reflowed = reflow_line(&combined_content, &adjusted_options);
3858            for (j, reflowed_line) in reflowed.iter().enumerate() {
3859                if j == 0 {
3860                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
3861                } else {
3862                    // Continuation lines aligned with text after marker
3863                    let continuation_indent = " ".repeat(continuation_spaces);
3864                    result.push(format!("{continuation_indent}{reflowed_line}"));
3865                }
3866            }
3867            continue;
3868        }
3869
3870        // Preserve tables
3871        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
3872            result.push(line.to_string());
3873            i += 1;
3874            continue;
3875        }
3876
3877        // Preserve reference definitions
3878        if trimmed.starts_with('[') && line.contains("]:") {
3879            result.push(line.to_string());
3880            i += 1;
3881            continue;
3882        }
3883
3884        // Preserve definition list items (extended markdown)
3885        if is_definition_list_item(trimmed) {
3886            result.push(line.to_string());
3887            i += 1;
3888            continue;
3889        }
3890
3891        // Check if this is a single line that doesn't need processing
3892        let mut is_single_line_paragraph = true;
3893        if i + 1 < lines.len() {
3894            let next_trimmed = lines[i + 1].trim();
3895            // Check if next line continues this paragraph
3896            if !is_block_boundary(next_trimmed) {
3897                is_single_line_paragraph = false;
3898            }
3899        }
3900
3901        // If it's a single line that fits, just add it as-is
3902        if is_single_line_paragraph && line_fits(line, options) {
3903            result.push(line.to_string());
3904            i += 1;
3905            continue;
3906        }
3907
3908        // For regular paragraphs, collect consecutive lines
3909        let mut paragraph_parts = Vec::new();
3910        let mut current_part = vec![line];
3911        i += 1;
3912
3913        // If preserve_breaks is true, treat each line separately
3914        if options.preserve_breaks {
3915            // Don't collect consecutive lines - just reflow this single line
3916            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
3917                Some("\\")
3918            } else if line.ends_with("  ") {
3919                Some("  ")
3920            } else {
3921                None
3922            };
3923            let reflowed = reflow_line(line, options);
3924
3925            // Preserve hard breaks (two trailing spaces or backslash)
3926            if let Some(break_marker) = hard_break_type {
3927                if !reflowed.is_empty() {
3928                    let mut reflowed_with_break = reflowed;
3929                    let last_idx = reflowed_with_break.len() - 1;
3930                    if !has_hard_break(&reflowed_with_break[last_idx]) {
3931                        reflowed_with_break[last_idx].push_str(break_marker);
3932                    }
3933                    result.extend(reflowed_with_break);
3934                }
3935            } else {
3936                result.extend(reflowed);
3937            }
3938        } else {
3939            // Original behavior: collect consecutive lines into a paragraph
3940            while i < lines.len() {
3941                let prev_line = if !current_part.is_empty() {
3942                    current_part.last().unwrap()
3943                } else {
3944                    ""
3945                };
3946                let next_line = lines[i];
3947                let next_trimmed = next_line.trim();
3948
3949                // Stop at empty lines or special blocks
3950                if is_block_boundary(next_trimmed) {
3951                    break;
3952                }
3953
3954                // Check if previous line ends with hard break (two spaces or backslash)
3955                // or is a complete sentence in sentence_per_line mode
3956                let prev_trimmed = prev_line.trim();
3957                let abbreviations = get_abbreviations(&options.abbreviations);
3958                let ends_with_sentence = (prev_trimmed.ends_with('.')
3959                    || prev_trimmed.ends_with('!')
3960                    || prev_trimmed.ends_with('?')
3961                    || prev_trimmed.ends_with(".*")
3962                    || prev_trimmed.ends_with("!*")
3963                    || prev_trimmed.ends_with("?*")
3964                    || prev_trimmed.ends_with("._")
3965                    || prev_trimmed.ends_with("!_")
3966                    || prev_trimmed.ends_with("?_")
3967                    // Quote-terminated sentences (straight and curly quotes)
3968                    || prev_trimmed.ends_with(".\"")
3969                    || prev_trimmed.ends_with("!\"")
3970                    || prev_trimmed.ends_with("?\"")
3971                    || prev_trimmed.ends_with(".'")
3972                    || prev_trimmed.ends_with("!'")
3973                    || prev_trimmed.ends_with("?'")
3974                    || prev_trimmed.ends_with(".\u{201D}")
3975                    || prev_trimmed.ends_with("!\u{201D}")
3976                    || prev_trimmed.ends_with("?\u{201D}")
3977                    || prev_trimmed.ends_with(".\u{2019}")
3978                    || prev_trimmed.ends_with("!\u{2019}")
3979                    || prev_trimmed.ends_with("?\u{2019}"))
3980                    && !text_ends_with_abbreviation(
3981                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
3982                        &abbreviations,
3983                    );
3984
3985                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
3986                    // Start a new part after hard break or complete sentence
3987                    paragraph_parts.push(current_part.join(" "));
3988                    current_part = vec![next_line];
3989                } else {
3990                    current_part.push(next_line);
3991                }
3992                i += 1;
3993            }
3994
3995            // Add the last part
3996            if !current_part.is_empty() {
3997                if current_part.len() == 1 {
3998                    // Single line, don't add trailing space
3999                    paragraph_parts.push(current_part[0].to_string());
4000                } else {
4001                    paragraph_parts.push(current_part.join(" "));
4002                }
4003            }
4004
4005            // Reflow each part separately, preserving hard breaks
4006            for (j, part) in paragraph_parts.iter().enumerate() {
4007                let reflowed = reflow_line(part, options);
4008                result.extend(reflowed);
4009
4010                // Preserve hard break by ensuring last line of part ends with hard break marker
4011                // Use two spaces as the default hard break format for reflows
4012                // But don't add hard breaks in sentence_per_line mode - lines are already separate
4013                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4014                    let last_idx = result.len() - 1;
4015                    if !has_hard_break(&result[last_idx]) {
4016                        result[last_idx].push_str("  ");
4017                    }
4018                }
4019            }
4020        }
4021    }
4022
4023    // Preserve trailing newline if the original content had one
4024    let result_text = result.join("\n");
4025    if content.ends_with('\n') && !result_text.ends_with('\n') {
4026        format!("{result_text}\n")
4027    } else {
4028        result_text
4029    }
4030}
4031
4032/// Information about a reflowed paragraph
4033#[derive(Debug, Clone)]
4034pub struct ParagraphReflow {
4035    /// Starting byte offset of the paragraph in the original content
4036    pub start_byte: usize,
4037    /// Ending byte offset of the paragraph in the original content
4038    pub end_byte: usize,
4039    /// The reflowed text for this paragraph
4040    pub reflowed_text: String,
4041}
4042
4043/// A collected blockquote line used for style-preserving reflow.
4044///
4045/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
4046/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
4047/// rather than constructing the struct directly.
4048#[derive(Debug, Clone)]
4049pub struct BlockquoteLineData {
4050    /// Trimmed content without the `> ` prefix.
4051    pub(crate) content: String,
4052    /// Whether this line carries an explicit blockquote marker.
4053    pub(crate) is_explicit: bool,
4054    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
4055    pub(crate) prefix: Option<String>,
4056}
4057
4058impl BlockquoteLineData {
4059    /// Create an explicit (marker-bearing) blockquote line.
4060    pub fn explicit(content: String, prefix: String) -> Self {
4061        Self {
4062            content,
4063            is_explicit: true,
4064            prefix: Some(prefix),
4065        }
4066    }
4067
4068    /// Create a lazy continuation line (no blockquote marker).
4069    pub fn lazy(content: String) -> Self {
4070        Self {
4071            content,
4072            is_explicit: false,
4073            prefix: None,
4074        }
4075    }
4076}
4077
4078/// Style for blockquote continuation lines after reflow.
4079#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4080pub enum BlockquoteContinuationStyle {
4081    Explicit,
4082    Lazy,
4083}
4084
4085/// Determine the continuation style for a blockquote paragraph from its collected lines.
4086///
4087/// The first line is always explicit (it carries the marker), so only continuation
4088/// lines (index 1+) are counted. Ties resolve to `Explicit`.
4089///
4090/// When the slice has only one element (no continuation lines to inspect), both
4091/// counts are zero and the tie-breaking rule returns `Explicit`.
4092pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4093    let mut explicit_count = 0usize;
4094    let mut lazy_count = 0usize;
4095
4096    for line in lines.iter().skip(1) {
4097        if line.is_explicit {
4098            explicit_count += 1;
4099        } else {
4100            lazy_count += 1;
4101        }
4102    }
4103
4104    if explicit_count > 0 && lazy_count == 0 {
4105        BlockquoteContinuationStyle::Explicit
4106    } else if lazy_count > 0 && explicit_count == 0 {
4107        BlockquoteContinuationStyle::Lazy
4108    } else if explicit_count >= lazy_count {
4109        BlockquoteContinuationStyle::Explicit
4110    } else {
4111        BlockquoteContinuationStyle::Lazy
4112    }
4113}
4114
4115/// Determine the dominant blockquote prefix for a paragraph.
4116///
4117/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
4118/// first appearance. Falls back to `fallback` when no explicit lines are present.
4119pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4120    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4121
4122    for (idx, line) in lines.iter().enumerate() {
4123        let Some(prefix) = line.prefix.as_ref() else {
4124            continue;
4125        };
4126        counts
4127            .entry(prefix.clone())
4128            .and_modify(|entry| entry.0 += 1)
4129            .or_insert((1, idx));
4130    }
4131
4132    counts
4133        .into_iter()
4134        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4135            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4136        })
4137        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4138}
4139
4140/// Whether a reflowed blockquote content line must carry an explicit prefix.
4141///
4142/// Lines that would start a new block structure (headings, fences, lists, etc.)
4143/// cannot safely use lazy continuation syntax.
4144pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4145    let trimmed = content_line.trim_start();
4146    trimmed.starts_with('>')
4147        || trimmed.starts_with('#')
4148        || trimmed.starts_with("```")
4149        || trimmed.starts_with("~~~")
4150        || is_unordered_list_marker(trimmed)
4151        || is_numbered_list_item(trimmed)
4152        || is_horizontal_rule(trimmed)
4153        || is_definition_list_item(trimmed)
4154        || (trimmed.starts_with('[') && trimmed.contains("]:"))
4155        || trimmed.starts_with(":::")
4156        || (trimmed.starts_with('<')
4157            && !trimmed.starts_with("<http")
4158            && !trimmed.starts_with("<https")
4159            && !trimmed.starts_with("<mailto:"))
4160}
4161
4162/// Reflow blockquote content lines and apply continuation style.
4163///
4164/// Segments separated by hard breaks are reflowed independently. The output lines
4165/// receive blockquote prefixes according to `continuation_style`: the first line and
4166/// any line that would start a new block structure always get an explicit prefix;
4167/// other lines follow the detected style.
4168///
4169/// Returns the styled, reflowed lines (without a trailing newline).
4170pub fn reflow_blockquote_content(
4171    lines: &[BlockquoteLineData],
4172    explicit_prefix: &str,
4173    continuation_style: BlockquoteContinuationStyle,
4174    options: &ReflowOptions,
4175) -> Vec<String> {
4176    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4177    let segments = split_into_segments_strs(&content_strs);
4178    let mut reflowed_content_lines: Vec<String> = Vec::new();
4179
4180    for segment in segments {
4181        let hard_break_type = segment.last().and_then(|&line| {
4182            let line = line.strip_suffix('\r').unwrap_or(line);
4183            if line.ends_with('\\') {
4184                Some("\\")
4185            } else if line.ends_with("  ") {
4186                Some("  ")
4187            } else {
4188                None
4189            }
4190        });
4191
4192        let pieces: Vec<&str> = segment
4193            .iter()
4194            .map(|&line| {
4195                if let Some(l) = line.strip_suffix('\\') {
4196                    l.trim_end()
4197                } else if let Some(l) = line.strip_suffix("  ") {
4198                    l.trim_end()
4199                } else {
4200                    line.trim_end()
4201                }
4202            })
4203            .collect();
4204
4205        let segment_text = pieces.join(" ");
4206        let segment_text = segment_text.trim();
4207        if segment_text.is_empty() {
4208            continue;
4209        }
4210
4211        let mut reflowed = reflow_line(segment_text, options);
4212        if let Some(break_marker) = hard_break_type
4213            && !reflowed.is_empty()
4214        {
4215            let last_idx = reflowed.len() - 1;
4216            if !has_hard_break(&reflowed[last_idx]) {
4217                reflowed[last_idx].push_str(break_marker);
4218            }
4219        }
4220        reflowed_content_lines.extend(reflowed);
4221    }
4222
4223    let mut styled_lines: Vec<String> = Vec::new();
4224    for (idx, line) in reflowed_content_lines.iter().enumerate() {
4225        let force_explicit = idx == 0
4226            || continuation_style == BlockquoteContinuationStyle::Explicit
4227            || should_force_explicit_blockquote_line(line);
4228        if force_explicit {
4229            styled_lines.push(format!("{explicit_prefix}{line}"));
4230        } else {
4231            styled_lines.push(line.clone());
4232        }
4233    }
4234
4235    styled_lines
4236}
4237
4238fn is_blockquote_content_boundary(content: &str) -> bool {
4239    let trimmed = content.trim();
4240    trimmed.is_empty()
4241        || is_block_boundary(trimmed)
4242        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4243        || trimmed.starts_with(":::")
4244        || crate::utils::is_template_directive_only(content)
4245        || is_standalone_attr_list(content)
4246        || is_snippet_block_delimiter(content)
4247}
4248
4249fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4250    let mut segments = Vec::new();
4251    let mut current = Vec::new();
4252
4253    for &line in lines {
4254        current.push(line);
4255        if has_hard_break(line) {
4256            segments.push(current);
4257            current = Vec::new();
4258        }
4259    }
4260
4261    if !current.is_empty() {
4262        segments.push(current);
4263    }
4264
4265    segments
4266}
4267
4268fn reflow_blockquote_paragraph_at_line(
4269    content: &str,
4270    lines: &[&str],
4271    target_idx: usize,
4272    options: &ReflowOptions,
4273) -> Option<ParagraphReflow> {
4274    let mut anchor_idx = target_idx;
4275    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4276        parsed.nesting_level
4277    } else {
4278        let mut found = None;
4279        let mut idx = target_idx;
4280        loop {
4281            if lines[idx].trim().is_empty() {
4282                break;
4283            }
4284            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4285                found = Some((idx, parsed.nesting_level));
4286                break;
4287            }
4288            if idx == 0 {
4289                break;
4290            }
4291            idx -= 1;
4292        }
4293        let (idx, level) = found?;
4294        anchor_idx = idx;
4295        level
4296    };
4297
4298    // Expand backward to capture prior quote content at the same nesting level.
4299    let mut para_start = anchor_idx;
4300    while para_start > 0 {
4301        let prev_idx = para_start - 1;
4302        let prev_line = lines[prev_idx];
4303
4304        if prev_line.trim().is_empty() {
4305            break;
4306        }
4307
4308        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4309            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4310                break;
4311            }
4312            para_start = prev_idx;
4313            continue;
4314        }
4315
4316        let prev_lazy = prev_line.trim_start();
4317        if is_blockquote_content_boundary(prev_lazy) {
4318            break;
4319        }
4320        para_start = prev_idx;
4321    }
4322
4323    // Lazy continuation cannot precede the first explicit marker.
4324    while para_start < lines.len() {
4325        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4326            para_start += 1;
4327            continue;
4328        };
4329        target_level = parsed.nesting_level;
4330        break;
4331    }
4332
4333    if para_start >= lines.len() || para_start > target_idx {
4334        return None;
4335    }
4336
4337    // Collect explicit lines at target level and lazy continuation lines.
4338    // Each entry is (original_line_idx, BlockquoteLineData).
4339    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4340    let mut idx = para_start;
4341    while idx < lines.len() {
4342        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4343            break;
4344        }
4345
4346        let line = lines[idx];
4347        if line.trim().is_empty() {
4348            break;
4349        }
4350
4351        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4352            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4353                break;
4354            }
4355            collected.push((
4356                idx,
4357                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4358            ));
4359            idx += 1;
4360            continue;
4361        }
4362
4363        let lazy_content = line.trim_start();
4364        if is_blockquote_content_boundary(lazy_content) {
4365            break;
4366        }
4367
4368        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4369        idx += 1;
4370    }
4371
4372    if collected.is_empty() {
4373        return None;
4374    }
4375
4376    let para_end = collected[collected.len() - 1].0;
4377    if target_idx < para_start || target_idx > para_end {
4378        return None;
4379    }
4380
4381    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4382
4383    let fallback_prefix = line_data
4384        .iter()
4385        .find_map(|d| d.prefix.clone())
4386        .unwrap_or_else(|| "> ".to_string());
4387    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4388    let continuation_style = blockquote_continuation_style(&line_data);
4389
4390    let adjusted_line_length = options
4391        .line_length
4392        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4393        .max(1);
4394
4395    let adjusted_options = ReflowOptions {
4396        line_length: adjusted_line_length,
4397        ..options.clone()
4398    };
4399
4400    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4401
4402    if styled_lines.is_empty() {
4403        return None;
4404    }
4405
4406    // Calculate byte offsets.
4407    let mut start_byte = 0;
4408    for line in lines.iter().take(para_start) {
4409        start_byte += line.len() + 1;
4410    }
4411
4412    let mut end_byte = start_byte;
4413    for line in lines.iter().take(para_end + 1).skip(para_start) {
4414        end_byte += line.len() + 1;
4415    }
4416
4417    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4418    if !includes_trailing_newline {
4419        end_byte -= 1;
4420    }
4421
4422    let reflowed_joined = styled_lines.join("\n");
4423    let reflowed_text = if includes_trailing_newline {
4424        if reflowed_joined.ends_with('\n') {
4425            reflowed_joined
4426        } else {
4427            format!("{reflowed_joined}\n")
4428        }
4429    } else if reflowed_joined.ends_with('\n') {
4430        reflowed_joined.trim_end_matches('\n').to_string()
4431    } else {
4432        reflowed_joined
4433    };
4434
4435    Some(ParagraphReflow {
4436        start_byte,
4437        end_byte,
4438        reflowed_text,
4439    })
4440}
4441
4442/// Reflow a single paragraph at the specified line number
4443///
4444/// This function finds the paragraph containing the given line number,
4445/// reflows it according to the specified line length, and returns
4446/// information about the paragraph location and its reflowed text.
4447///
4448/// # Arguments
4449///
4450/// * `content` - The full document content
4451/// * `line_number` - The 1-based line number within the paragraph to reflow
4452/// * `line_length` - The target line length for reflowing
4453///
4454/// # Returns
4455///
4456/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
4457/// or `None` if the line number is out of bounds or the content at that
4458/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
4459pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4460    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4461}
4462
4463/// Reflow a paragraph at the given line with a specific length mode.
4464pub fn reflow_paragraph_at_line_with_mode(
4465    content: &str,
4466    line_number: usize,
4467    line_length: usize,
4468    length_mode: ReflowLengthMode,
4469) -> Option<ParagraphReflow> {
4470    let options = ReflowOptions {
4471        line_length,
4472        length_mode,
4473        ..Default::default()
4474    };
4475    reflow_paragraph_at_line_with_options(content, line_number, &options)
4476}
4477
4478/// Reflow a paragraph at the given line using the provided options.
4479///
4480/// This is the canonical implementation used by both the rule's fix mode and the
4481/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
4482/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
4483///
4484/// # Returns
4485///
4486/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
4487/// if the line is out of bounds or sits inside a non-reflow-able construct.
4488pub fn reflow_paragraph_at_line_with_options(
4489    content: &str,
4490    line_number: usize,
4491    options: &ReflowOptions,
4492) -> Option<ParagraphReflow> {
4493    if line_number == 0 {
4494        return None;
4495    }
4496
4497    let lines: Vec<&str> = content.lines().collect();
4498
4499    // Check if line number is valid (1-based)
4500    if line_number > lines.len() {
4501        return None;
4502    }
4503
4504    let target_idx = line_number - 1; // Convert to 0-based
4505    let target_line = lines[target_idx];
4506    let trimmed = target_line.trim();
4507
4508    // Handle blockquote paragraphs (including lazy continuation lines) with
4509    // style-preserving output.
4510    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4511        return Some(blockquote_reflow);
4512    }
4513
4514    // Don't reflow special blocks
4515    if is_paragraph_boundary(trimmed, target_line) {
4516        return None;
4517    }
4518
4519    // Find paragraph start - scan backward until blank line or special block
4520    let mut para_start = target_idx;
4521    while para_start > 0 {
4522        let prev_idx = para_start - 1;
4523        let prev_line = lines[prev_idx];
4524        let prev_trimmed = prev_line.trim();
4525
4526        // Stop at blank line or special blocks
4527        if is_paragraph_boundary(prev_trimmed, prev_line) {
4528            break;
4529        }
4530
4531        para_start = prev_idx;
4532    }
4533
4534    // Find paragraph end - scan forward until blank line or special block
4535    let mut para_end = target_idx;
4536    while para_end + 1 < lines.len() {
4537        let next_idx = para_end + 1;
4538        let next_line = lines[next_idx];
4539        let next_trimmed = next_line.trim();
4540
4541        // Stop at blank line or special blocks
4542        if is_paragraph_boundary(next_trimmed, next_line) {
4543            break;
4544        }
4545
4546        para_end = next_idx;
4547    }
4548
4549    // Extract paragraph lines
4550    let paragraph_lines = &lines[para_start..=para_end];
4551
4552    // Calculate byte offsets
4553    let mut start_byte = 0;
4554    for line in lines.iter().take(para_start) {
4555        start_byte += line.len() + 1; // +1 for newline
4556    }
4557
4558    let mut end_byte = start_byte;
4559    for line in paragraph_lines {
4560        end_byte += line.len() + 1; // +1 for newline
4561    }
4562
4563    // Track whether the byte range includes a trailing newline
4564    // (it doesn't if this is the last line and the file doesn't end with newline)
4565    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4566
4567    // Adjust end_byte if the last line doesn't have a newline
4568    if !includes_trailing_newline {
4569        end_byte -= 1;
4570    }
4571
4572    // Join paragraph lines and reflow
4573    let paragraph_text = paragraph_lines.join("\n");
4574
4575    // Reflow the paragraph using reflow_markdown to handle it properly
4576    let reflowed = reflow_markdown(&paragraph_text, options);
4577
4578    // Ensure reflowed text matches whether the byte range includes a trailing newline
4579    // This is critical: if the range includes a newline, the replacement must too,
4580    // otherwise the next line will get appended to the reflowed paragraph
4581    let reflowed_text = if includes_trailing_newline {
4582        // Range includes newline - ensure reflowed text has one
4583        if reflowed.ends_with('\n') {
4584            reflowed
4585        } else {
4586            format!("{reflowed}\n")
4587        }
4588    } else {
4589        // Range doesn't include newline - ensure reflowed text doesn't have one
4590        if reflowed.ends_with('\n') {
4591            reflowed.trim_end_matches('\n').to_string()
4592        } else {
4593            reflowed
4594        }
4595    };
4596
4597    Some(ParagraphReflow {
4598        start_byte,
4599        end_byte,
4600        reflowed_text,
4601    })
4602}
4603/// Decomposes a raw inline code span string into its inner content and backtick marker.
4604///
4605/// For example, `decompose_code_span("`code`")` returns `Some(("code", "`"))`.
4606/// If the input is not a valid code span (e.g., it doesn't start and end with the
4607/// same number of backticks), returns `None`.
4608fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4609    let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4610    if marker_len == 0 {
4611        return None;
4612    }
4613    let marker = &raw[..marker_len];
4614    if raw.len() < marker_len * 2 {
4615        return None;
4616    }
4617    let content = &raw[marker_len..raw.len() - marker_len];
4618    Some((content, marker))
4619}
4620
4621#[cfg(test)]
4622mod tests {
4623    use super::*;
4624
4625    /// `preserves_content` is the last line of defense against a reflow writing
4626    /// corrupted prose into a file, so it has to actually reject the ways a
4627    /// reflow can go wrong - not merely accept the ways it can go right.
4628    #[test]
4629    fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4630        let accepted: &[(&str, &[&str])] = &[
4631            ("one two three", &["one two three"]),
4632            ("one two three", &["one two", "three"]),
4633            ("one two three", &["one", "two", "three"]),
4634            // Collapsing runs of whitespace and dropping trailing whitespace
4635            ("one   two  ", &["one two"]),
4636            // A script written without spaces has to break somewhere
4637            ("日本語のテキスト", &["日本語の", "テキスト"]),
4638            // Markers move to the line their content moved to
4639            ("_First. Second._", &["_First.", "Second._"]),
4640        ];
4641        for (original, reflowed) in accepted {
4642            let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4643            assert!(
4644                preserves_content(original, &reflowed),
4645                "{original:?} -> {reflowed:?} only moves whitespace"
4646            );
4647        }
4648
4649        let rejected: &[(&str, &[&str])] = &[
4650            // Dropped
4651            ("one two three", &["one two"]),
4652            // Invented
4653            ("one two", &["one two three"]),
4654            // Reordered
4655            ("one two", &["two one"]),
4656            // Duplicated
4657            ("_First. Second._", &["_First._", "_Second._"]),
4658            // Two words glued into one
4659            ("alpha and beta", &["alpha", "andbeta"]),
4660            // A space deleted around punctuation
4661            ("mot suivant : autre", &["mot suivant: autre"]),
4662        ];
4663        for (original, reflowed) in rejected {
4664            let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4665            assert!(
4666                !preserves_content(original, &reflowed),
4667                "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4668            );
4669        }
4670    }
4671
4672    /// A rejected reflow leaves the line alone rather than writing the damage.
4673    #[test]
4674    fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4675        let options = ReflowOptions {
4676            line_length: 40,
4677            ..Default::default()
4678        };
4679        let line = "one two three four five six seven eight nine ten";
4680
4681        assert!(preserves_content(line, &reflow_line(line, &options)));
4682        assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4683    }
4684
4685    #[test]
4686    fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4687        // A single line of thousands of words once drove `cascade_split_line`
4688        // into deep recursion (stack overflow / hang). The iterative version
4689        // must complete and split it into many lines that each fit the width and
4690        // that together preserve every word. The test finishing at all is the
4691        // core assertion (no stack overflow); the content checks guard behavior.
4692        let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4693        let line = words.join(" ");
4694
4695        let options = ReflowOptions {
4696            line_length: 80,
4697            length_mode: ReflowLengthMode::Chars,
4698            ..Default::default()
4699        };
4700        let out = cascade_split_line(&line, &options);
4701
4702        assert!(out.len() > 1, "a very long line should split into many lines");
4703        for segment in &out {
4704            assert!(
4705                display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4706                "each wrapped line should fit the width (or be a single unbreakable token)"
4707            );
4708        }
4709        // Every original word survives, in order.
4710        let rejoined = out.join(" ");
4711        let original_words: Vec<&str> = line.split(' ').collect();
4712        let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4713        assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4714    }
4715
4716    /// Unit test for private helper function text_ends_with_abbreviation()
4717    ///
4718    /// This test stays inline because it tests a private function.
4719    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
4720    #[test]
4721    fn test_helper_function_text_ends_with_abbreviation() {
4722        // Test the helper function directly
4723        let abbreviations = get_abbreviations(&None);
4724
4725        // True cases - built-in abbreviations (titles and i.e./e.g.)
4726        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4727        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4728        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4729        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4730        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4731        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4732        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4733        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4734
4735        // False cases - NOT in built-in list (etc doesn't always have period)
4736        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4737        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4738        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4739        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4740        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4741        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
4742        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
4743        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
4744        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
4745        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
4746    }
4747
4748    #[test]
4749    fn test_footnote_after_period_splits_sentence() {
4750        // A footnote reference glued to the period (no space) must not swallow
4751        // the sentence boundary; the reference stays attached to the sentence
4752        // it annotates.
4753        let text = "First sentence.[^1] Second sentence.";
4754        let sentences = split_into_sentences(text);
4755        assert_eq!(
4756            sentences,
4757            vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4758            "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4759        );
4760    }
4761
4762    #[test]
4763    fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4764        // Multiple footnote references glued back-to-back after the period.
4765        let text = "Notes here.[^1][^2] Second sentence.";
4766        let sentences = split_into_sentences(text);
4767        assert_eq!(
4768            sentences,
4769            vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4770        );
4771    }
4772
4773    #[test]
4774    fn test_footnote_before_period_still_splits_sentence() {
4775        // Control: a footnote reference before the period was already followed
4776        // by a space, so this boundary worked before this fix and must keep
4777        // working.
4778        let text = "Annotation here[^1]. Second sentence.";
4779        let sentences = split_into_sentences(text);
4780        assert_eq!(
4781            sentences,
4782            vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4783        );
4784    }
4785
4786    #[test]
4787    fn test_mid_sentence_footnote_does_not_split() {
4788        // A footnote reference not glued to sentence-ending punctuation must not
4789        // introduce a spurious boundary at the bracket itself.
4790        let text = "The system word[^1] more words. Next sentence.";
4791        let sentences = split_into_sentences(text);
4792        assert_eq!(
4793            sentences,
4794            vec![
4795                "The system word[^1] more words.".to_string(),
4796                "Next sentence.".to_string()
4797            ]
4798        );
4799    }
4800
4801    #[test]
4802    fn test_bare_numeric_bracket_after_period_does_not_split() {
4803        // A bare `[1]` is link/citation-like text, not footnote syntax; the fix
4804        // is scoped to `[^label]` only.
4805        let text = "Citation here.[1] Second sentence.";
4806        let sentences = split_into_sentences(text);
4807        assert_eq!(
4808            sentences,
4809            vec![text.to_string()],
4810            "a bare numeric bracket must not be treated as a sentence boundary"
4811        );
4812    }
4813
4814    #[test]
4815    fn test_footnote_glued_to_following_word_does_not_split() {
4816        // No whitespace after the footnote reference means there is nowhere a
4817        // next sentence can start, so this must not be treated as a boundary.
4818        let text = "First sentence.[^1]Continued glued text.";
4819        let sentences = split_into_sentences(text);
4820        assert_eq!(sentences, vec![text.to_string()]);
4821    }
4822
4823    #[test]
4824    fn test_footnote_at_end_of_text_is_preserved() {
4825        // A footnote reference at the very end of the text has nothing after it
4826        // to split off; it is preserved as part of the single trailing sentence.
4827        let text = "Sentence.[^1]";
4828        let sentences = split_into_sentences(text);
4829        assert_eq!(sentences, vec![text.to_string()]);
4830    }
4831
4832    #[test]
4833    fn test_abbreviation_before_footnote_does_not_split() {
4834        // The existing abbreviation guard must still apply when a footnote
4835        // reference immediately follows the abbreviation's period.
4836        let text = "See the notes, e.g.[^1] this one.";
4837        let sentences = split_into_sentences(text);
4838        assert_eq!(
4839            sentences,
4840            vec![text.to_string()],
4841            "e.g. is an abbreviation, not a sentence boundary"
4842        );
4843    }
4844
4845    #[test]
4846    fn test_is_unordered_list_marker() {
4847        // Valid unordered list markers
4848        assert!(is_unordered_list_marker("- item"));
4849        assert!(is_unordered_list_marker("* item"));
4850        assert!(is_unordered_list_marker("+ item"));
4851        assert!(is_unordered_list_marker("-")); // lone marker
4852        assert!(is_unordered_list_marker("*"));
4853        assert!(is_unordered_list_marker("+"));
4854
4855        // Not list markers
4856        assert!(!is_unordered_list_marker("---")); // horizontal rule
4857        assert!(!is_unordered_list_marker("***")); // horizontal rule
4858        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
4859        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
4860        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
4861        assert!(!is_unordered_list_marker("-word")); // no space after marker
4862        assert!(!is_unordered_list_marker("")); // empty
4863        assert!(!is_unordered_list_marker("text")); // plain text
4864        assert!(!is_unordered_list_marker("# heading")); // heading
4865    }
4866
4867    #[test]
4868    fn test_is_block_boundary() {
4869        // Block boundaries
4870        assert!(is_block_boundary("")); // empty line
4871        assert!(is_block_boundary("# Heading")); // ATX heading
4872        assert!(is_block_boundary("## Level 2")); // ATX heading
4873        assert!(is_block_boundary("```rust")); // code fence
4874        assert!(is_block_boundary("~~~")); // tilde code fence
4875        assert!(is_block_boundary("> quote")); // blockquote
4876        assert!(is_block_boundary("| cell |")); // table
4877        assert!(is_block_boundary("[link]: http://example.com")); // reference def
4878        assert!(is_block_boundary("---")); // horizontal rule
4879        assert!(is_block_boundary("***")); // horizontal rule
4880        assert!(is_block_boundary("- item")); // unordered list
4881        assert!(is_block_boundary("* item")); // unordered list
4882        assert!(is_block_boundary("+ item")); // unordered list
4883        assert!(is_block_boundary("1. item")); // ordered list
4884        assert!(is_block_boundary("10. item")); // ordered list
4885        assert!(is_block_boundary(": definition")); // definition list
4886        assert!(is_block_boundary(":::")); // div marker
4887        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
4888
4889        // NOT block boundaries (paragraph continuation)
4890        assert!(!is_block_boundary("regular text"));
4891        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
4892        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
4893        assert!(!is_block_boundary("some words here"));
4894    }
4895
4896    #[test]
4897    fn test_definition_list_boundary_in_single_line_paragraph() {
4898        // Verifies that a definition list item after a single-line paragraph
4899        // is treated as a block boundary, not merged into the paragraph
4900        let options = ReflowOptions {
4901            line_length: 80,
4902            ..Default::default()
4903        };
4904        let input = "Term\n: Definition of the term";
4905        let result = reflow_markdown(input, &options);
4906        // The definition list marker should remain on its own line
4907        assert!(
4908            result.contains(": Definition"),
4909            "Definition list item should not be merged into previous line. Got: {result:?}"
4910        );
4911        let lines: Vec<&str> = result.lines().collect();
4912        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
4913        assert_eq!(lines[0], "Term");
4914        assert_eq!(lines[1], ": Definition of the term");
4915    }
4916
4917    #[test]
4918    fn test_is_paragraph_boundary() {
4919        // Core block boundary checks are inherited
4920        assert!(is_paragraph_boundary("# Heading", "# Heading"));
4921        assert!(is_paragraph_boundary("- item", "- item"));
4922        assert!(is_paragraph_boundary(":::", ":::"));
4923        assert!(is_paragraph_boundary(": definition", ": definition"));
4924
4925        // Indented code blocks (≥4 spaces or tab)
4926        assert!(is_paragraph_boundary("code", "    code"));
4927        assert!(is_paragraph_boundary("code", "\tcode"));
4928
4929        // Table rows via is_potential_table_row
4930        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
4931        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
4932
4933        // Not paragraph boundaries
4934        assert!(!is_paragraph_boundary("regular text", "regular text"));
4935        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
4936    }
4937
4938    #[test]
4939    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
4940        // Verifies that div markers (:::) are treated as paragraph boundaries
4941        // in reflow_paragraph_at_line, preventing reflow across div boundaries
4942        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
4943        // Line 3 is the div marker — should not be reflowed
4944        let result = reflow_paragraph_at_line(content, 3, 80);
4945        assert!(result.is_none(), "Div marker line should not be reflowed");
4946    }
4947
4948    #[test]
4949    fn starts_block_construct_detects_block_openers() {
4950        // Bullet list markers: marker char followed by space or end
4951        for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
4952            assert!(starts_block_construct(case), "bullet: {case:?}");
4953        }
4954        // Ordered list markers: only a list numbered 1 with a non-empty first
4955        // item interrupts a paragraph. Leading zeros keep the number 1.
4956        for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
4957            assert!(starts_block_construct(case), "ordered: {case:?}");
4958        }
4959        // Blockquote: `>` needs no following space
4960        for case in ["> quote", ">quote", ">"] {
4961            assert!(starts_block_construct(case), "blockquote: {case:?}");
4962        }
4963        // ATX headings: 1-6 hashes then space or end
4964        for case in ["# heading", "###### h6", "#", "##"] {
4965            assert!(starts_block_construct(case), "heading: {case:?}");
4966        }
4967        // Code fences: 3+ backticks or tildes
4968        for case in ["```", "```rust", "````", "~~~", "~~~text"] {
4969            assert!(starts_block_construct(case), "fence: {case:?}");
4970        }
4971        // Setext underlines and thematic breaks
4972        for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
4973            assert!(starts_block_construct(case), "setext/thematic: {case:?}");
4974        }
4975        // Footnote and link-reference definitions: hoisting one to line start
4976        // reclassifies it and can resolve dangling references elsewhere
4977        for case in [
4978            "[^1]: text",
4979            "[^note]:",
4980            "[ref]: http://example.com",
4981            "[wat]: url follows",
4982        ] {
4983            assert!(starts_block_construct(case), "definition: {case:?}");
4984        }
4985        // Block-level HTML tags (rumdl parser's HTML block classification)
4986        for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
4987            assert!(starts_block_construct(case), "html block: {case:?}");
4988        }
4989    }
4990
4991    #[test]
4992    fn starts_block_construct_allows_ordinary_prose() {
4993        for case in [
4994            "",
4995            "word",
4996            "-5 degrees",
4997            "--flag",
4998            "-item",
4999            "#hashtag",
5000            "####### seven hashes is not a heading",
5001            "1.5 million",
5002            "1234567890. ten digits is not a list marker",
5003            "0000000001. ten digits is not a list marker either",
5004            // A number other than 1 cannot interrupt a paragraph, nor can an
5005            // empty first item, so neither changes the parse at line start.
5006            "2. item",
5007            "7. item",
5008            "0. item",
5009            "42) x",
5010            "123456. item",
5011            "1.",
5012            "1)",
5013            "123456.",
5014            "123456)",
5015            "1.item",
5016            "1:30 pm",
5017            "*emphasis*",
5018            "**bold** text",
5019            "__bold__ text",
5020            "_emphasis_ text",
5021            "`code` span",
5022            "`` double backtick span ``",
5023            "~~strikethrough~~",
5024            "=x",
5025            "== ==",
5026            "(parenthetical)",
5027            "[link](url)",
5028            "[text][ref] more",
5029            "[bracketed] aside",
5030            "[a](b) [ref]: first bracket is a link, not a label",
5031            "[esc\\]: not a close] text",
5032            "<span>inline</span>",
5033            "<b>bold</b>",
5034            "<https://example.com> autolink",
5035            "<mailto:a@b.com>",
5036            "<notarealtag>",
5037        ] {
5038            assert!(!starts_block_construct(case), "prose: {case:?}");
5039        }
5040    }
5041
5042    #[test]
5043    fn merge_block_construct_continuations_merges_marker_led_lines() {
5044        let lines = vec![
5045            "First sentence?".to_string(),
5046            "- looks like a list item".to_string(),
5047            "Second sentence.".to_string(),
5048        ];
5049        assert_eq!(
5050            merge_block_construct_continuations(lines),
5051            vec![
5052                "First sentence? - looks like a list item".to_string(),
5053                "Second sentence.".to_string(),
5054            ]
5055        );
5056
5057        // The first line keeps its position: it replaces the paragraph's
5058        // original start, where the source already established the context.
5059        let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5060        assert_eq!(
5061            merge_block_construct_continuations(lines.clone()),
5062            lines,
5063            "first line must never be merged"
5064        );
5065
5066        // Folding cascades: `1.` alone is inert, but absorbing `[ref]:` makes
5067        // it a list item, so the grown line has to fold back in turn.
5068        let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5069        assert_eq!(
5070            merge_block_construct_continuations(lines),
5071            vec!["prose 1. [ref]:".to_string()],
5072            "a merge that creates an opener must fold again"
5073        );
5074    }
5075
5076    #[test]
5077    fn wrap_never_starts_a_line_with_a_block_marker() {
5078        let options = ReflowOptions {
5079            line_length: 25,
5080            ..Default::default()
5081        };
5082        // The dash lands exactly at the wrap point; the wrapper must break one
5083        // word earlier so the dash stays mid-line.
5084        let lines = reflow_line(
5085            "Some words here and then - a dash clause that wraps around the limit.",
5086            &options,
5087        );
5088        assert_eq!(
5089            lines,
5090            vec![
5091                "Some words here and",
5092                "then - a dash clause that",
5093                "wraps around the limit."
5094            ]
5095        );
5096
5097        // Every marker category must stay mid-line in wrap mode, whatever the width.
5098        for input in [
5099            "Alpha beta gamma delta epsilon - dash clause here to wrap",
5100            "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5101            "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5102            "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5103            "Alpha beta gamma delta epsilon * star clause here to wrap",
5104            "Alpha beta gamma delta epsilon + plus clause here to wrap",
5105        ] {
5106            for width in 10..40 {
5107                let options = ReflowOptions {
5108                    line_length: width,
5109                    ..Default::default()
5110                };
5111                for line in reflow_line(input, &options) {
5112                    assert!(
5113                        !starts_block_construct(&line),
5114                        "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5115                    );
5116                }
5117            }
5118        }
5119    }
5120
5121    #[test]
5122    fn sentence_per_line_keeps_block_markers_mid_line() {
5123        let options = ReflowOptions {
5124            line_length: 80,
5125            sentence_per_line: true,
5126            ..Default::default()
5127        };
5128        // A sentence "starting" with a dash must stay attached to the previous
5129        // sentence instead of becoming a list item (issue #728).
5130        let lines = reflow_line(
5131            "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5132            &options,
5133        );
5134        assert_eq!(
5135            lines,
5136            vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5137        );
5138
5139        // Same for heading, blockquote, and ordered-list lookalikes.
5140        let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5141        assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5142
5143        let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5144        assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5145
5146        let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5147        for line in &lines {
5148            assert!(
5149                !starts_block_construct(line),
5150                "sentence-per-line output opens a block construct: {line:?}"
5151            );
5152        }
5153    }
5154
5155    #[test]
5156    fn inline_math_directly_after_display_math_stays_atomic() {
5157        // The inline-math regex's lookbehind `(?<!\$)` is slice-start-sensitive:
5158        // a search anchored at the cursor accepts a `$` whose real predecessor
5159        // is a `$` (the lookbehind sees nothing before the slice), while a
5160        // cached search anchored earlier sees the `$` and rejects it. After
5161        // display math consumes `$$a$$`, the cursor sits directly after a `$`;
5162        // the match cache must re-search there or `$bb cc dd$` degrades to
5163        // plain text and gets wrapped apart, breaking math rendering.
5164        let options = ReflowOptions {
5165            line_length: 8,
5166            ..Default::default()
5167        };
5168        let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5169        assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5170    }
5171
5172    #[test]
5173    fn test_code_span_parsing() {
5174        // 1. Single backtick
5175        let elements = parse_markdown_elements_inner("`code`", false, false, None);
5176        assert_eq!(elements.len(), 1);
5177        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5178
5179        // 2. Double backtick
5180        let elements = parse_markdown_elements_inner("``code``", false, false, None);
5181        assert_eq!(elements.len(), 1);
5182        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5183
5184        // 3. Double backtick with single backtick inside
5185        let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5186        assert_eq!(elements.len(), 1);
5187        assert!(
5188            matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5189        );
5190
5191        // 4. Spaces inside
5192        let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5193        assert_eq!(elements.len(), 1);
5194        assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5195
5196        // 5. Unclosed backtick (should be parsed as Text)
5197        let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5198        assert_eq!(elements.len(), 1);
5199        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5200
5201        // 6. Unclosed backtick followed by a link (the link should be parsed as Link, not Text)
5202        let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5203        // We expect: Text("`unclosed "), Link("[link](url)")
5204        assert_eq!(elements.len(), 2);
5205        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5206        assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5207    }
5208
5209    #[test]
5210    fn test_reflow_performance_long_input() {
5211        // Generate a string with many distinct unclosed backtick runs to test worst-case performance.
5212        // E.g., "` `` ` `` ` ...`"
5213        let mut text = String::new();
5214        for i in 1..400 {
5215            let backticks = "`".repeat(i);
5216            text.push_str(&backticks);
5217            text.push(' ');
5218        }
5219
5220        let start = std::time::Instant::now();
5221        let elements = parse_markdown_elements_inner(&text, false, false, None);
5222        let duration = start.elapsed();
5223
5224        // Ensure it completes in under 100ms.
5225        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5226        assert!(!elements.is_empty());
5227    }
5228
5229    #[test]
5230    fn test_reflow_performance_display_math_heavy() {
5231        // Every consumed `$$a$$` leaves the cursor directly after a `$`. The
5232        // inline-math slice-start probe must run in place at the cursor; a
5233        // suffix rescan there makes this input quadratic (~9s in a debug
5234        // build for these 4000 spans).
5235        let text = "$$a$$".repeat(4000);
5236
5237        let start = std::time::Instant::now();
5238        let elements = parse_markdown_elements_inner(&text, false, false, None);
5239        let duration = start.elapsed();
5240
5241        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5242        assert_eq!(elements.len(), 4000);
5243    }
5244
5245    #[test]
5246    fn inline_math_len_at_start_matches_regex_at_slice_start() {
5247        // Exhaustive parity with INLINE_MATH_REGEX over short `$`-soup
5248        // strings: the helper must equal "regex match starting at position 0"
5249        // exactly, since the regex's leading lookbehind is vacuous at a slice
5250        // start. Any drift silently changes which math spans stay atomic.
5251        let alphabet = ['$', 'a', ' '];
5252        let mut inputs: Vec<String> = vec![String::new()];
5253        let mut frontier: Vec<String> = vec![String::new()];
5254        for _ in 0..6 {
5255            let mut longer = Vec::new();
5256            for prefix in &frontier {
5257                for ch in alphabet {
5258                    let mut s = prefix.clone();
5259                    s.push(ch);
5260                    longer.push(s);
5261                }
5262            }
5263            inputs.extend(longer.iter().cloned());
5264            frontier = longer;
5265        }
5266        // Multi-byte content must count bytes, not characters.
5267        inputs.push("$αβ$x".to_string());
5268        inputs.push("$α$$".to_string());
5269
5270        for s in &inputs {
5271            let expected = INLINE_MATH_REGEX
5272                .find(s)
5273                .ok()
5274                .flatten()
5275                .filter(|m| m.start() == 0)
5276                .map(|m| m.end());
5277            assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5278        }
5279    }
5280
5281    #[test]
5282    fn inline_math_probe_after_dollar_matches_uncached_parse() {
5283        // Expected element lists verified against the uncached parser (the
5284        // parent of the match-cache commit): when a consumed span leaves the
5285        // cursor directly after a `$`, the at-cursor probe must reproduce
5286        // exactly what rescanning the suffix used to find - both the hits
5287        // (the lookbehind is vacuous at the cursor) and the misses.
5288        let cases = [
5289            ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5290            (
5291                "$$a$$$b$ $$a$$$b$",
5292                r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5293            ),
5294            // Probe hit whose content is only whitespace.
5295            (
5296                "$$a$$$ x $y z$",
5297                r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5298            ),
5299            // Probe miss: `$$` after the cursor is not inline math.
5300            ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5301            ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5302            // Probe miss: the trailing lookahead rejects `$c$$`.
5303            (
5304                "$a$$b$$c$$d$ tail",
5305                r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5306            ),
5307        ];
5308        for (input, expected) in cases {
5309            let elements = parse_markdown_elements_inner(input, false, false, None);
5310            assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5311        }
5312    }
5313
5314    #[test]
5315    fn test_atomic_spans() {
5316        // --- Emphasis Spans ---
5317        let text_emphasis = "hello **word1 word2**";
5318
5319        let options_disabled = ReflowOptions {
5320            line_length: 18,
5321            atomic_spans: true,
5322            ..Default::default()
5323        };
5324        let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5325        assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5326
5327        let options_enabled = ReflowOptions {
5328            line_length: 18,
5329            atomic_spans: false,
5330            ..Default::default()
5331        };
5332        let lines_enabled = reflow_line(text_emphasis, &options_enabled);
5333        assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
5334
5335        // --- Code Spans ---
5336        let text_code = "hello `word1 word2`";
5337
5338        let lines_code_disabled = reflow_line(text_code, &options_disabled);
5339        assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
5340
5341        let lines_code_enabled = reflow_line(text_code, &options_enabled);
5342        assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
5343
5344        // Test multiple backticks with space padding
5345        let text_code_padding = "hello `` `word1` `word2` ``";
5346        let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
5347        assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
5348
5349        // Test atomic span wrapping with attached punctuation (maintainer feedback)
5350        let text_attached = "**one two**,"; // length 12, bold span is 11
5351
5352        // With limit 11, the bold span (11) fits, so it should NOT be split even though the total (12) exceeds 11.
5353        let options_11 = ReflowOptions {
5354            line_length: 11,
5355            atomic_spans: true,
5356            ..Default::default()
5357        };
5358        assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
5359
5360        // With limit 10, the bold span (11) exceeds 10, so it is allowed to be split.
5361        let options_10 = ReflowOptions {
5362            line_length: 10,
5363            atomic_spans: true,
5364            ..Default::default()
5365        };
5366        assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
5367    }
5368
5369    #[test]
5370    fn test_emphasis_containing_markers_is_not_split() {
5371        let options = ReflowOptions {
5372            line_length: 5,
5373            atomic_spans: false,
5374            ..Default::default()
5375        };
5376        // Emphasis containing internal markers (e.g. escaped asterisks) should not be split to avoid formatting corruption
5377        let lines = reflow_line(r#"*foo \*bar*"#, &options);
5378        assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
5379    }
5380
5381    /// The parsed shape of a markdown fragment, normalized the way wrapping is
5382    /// allowed to change it and no further: block/inline structure and
5383    /// code-span contents are compared exactly, while prose whitespace is
5384    /// collapsed, because a wrap only ever swaps a space for a newline.
5385    fn semantic_shape(markdown: &str) -> String {
5386        let mut options = Options::empty();
5387        options.insert(Options::ENABLE_STRIKETHROUGH);
5388        let mut out = String::new();
5389        let push_prose = |out: &mut String, text: &str| {
5390            for c in text.chars() {
5391                if c.is_whitespace() {
5392                    if !out.ends_with(char::is_whitespace) {
5393                        out.push(' ');
5394                    }
5395                } else {
5396                    out.push(c);
5397                }
5398            }
5399        };
5400        for event in Parser::new_ext(markdown, options) {
5401            match event {
5402                Event::Text(text) => push_prose(&mut out, &text),
5403                Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
5404                // Interior whitespace in a code span is literal: compare verbatim.
5405                Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
5406                Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
5407                Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
5408                other => out.push_str(&format!("{other:?}")),
5409            }
5410        }
5411        out.trim().to_string()
5412    }
5413
5414    #[test]
5415    fn test_wrapping_a_span_never_changes_what_it_parses_to() {
5416        // Breaking a span is only safe if the document still parses the same.
5417        // Cover both settings and several budgets so the break lands in a
5418        // different place in each run.
5419        let corpus = [
5420            "_This is a very, very, very, very, very long line with some `code` inside._",
5421            "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa lambda_",
5422            "**strong text with `code` and more words than fit on one single line**",
5423            "~~struck text with `code` and more words than fit on one single line~~",
5424            "_emphasis with **nested strong that is quite long** and trailing words_",
5425            // Doubly nested spans: the whole content of the outer span is one
5426            // nested span, so there is no prose outside it to break at.
5427            "***A doubly nested bold italic span with more words than fit on a line***",
5428            "___Another doubly nested span with more words than fit on a single line___",
5429            "**_mixed strong then emphasis with more words than fit on a single line_**",
5430            "*__mixed emphasis then strong with more words than fit on a single line__*",
5431            "**~~strong strikethrough with more words than fit on a single line here~~**",
5432            // A marker that belongs to no well-formed span. Breaking at these
5433            // spaces would start a line with `* `, making it a list item.
5434            "**a * b with a stray marker and plenty more words to pass the budget**",
5435            "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
5436            "text before _a long emphasis with `code` inside of it here_ and after",
5437            "(_a parenthesized long emphasis with `code` inside of it right here_)",
5438            r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
5439            "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
5440            // A link nested in the span: its destination and title are not prose
5441            // and cannot absorb a line break.
5442            "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
5443            "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
5444            r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
5445            "_A [link with a long label](https://example.com/path) and `code` here._",
5446            "_An image ![alt text here](<a b c d e f g h>) plus `code` and more text_",
5447        ];
5448        for text in corpus {
5449            let expected = semantic_shape(text);
5450            for line_length in [20, 30, 40, 80] {
5451                for atomic_spans in [true, false] {
5452                    let options = ReflowOptions {
5453                        line_length,
5454                        atomic_spans,
5455                        ..Default::default()
5456                    };
5457                    let wrapped = reflow_line(text, &options).join("\n");
5458                    assert_eq!(
5459                        semantic_shape(&wrapped),
5460                        expected,
5461                        "reflow changed the parse of {text:?} at line_length={line_length} \
5462                         atomic_spans={atomic_spans}\n  wrapped: {wrapped:?}"
5463                    );
5464                }
5465            }
5466        }
5467    }
5468
5469    #[test]
5470    fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
5471        // Wiki links, Hugo shortcodes and math are atomic elements at the top
5472        // level but are invisible to the CommonMark parser, so `semantic_shape`
5473        // cannot catch a break inside one. Assert directly that they survive.
5474        let cases = [
5475            (
5476                "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
5477                "[[a wiki link]]",
5478            ),
5479            (
5480                "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
5481                "{{< foo bar >}}",
5482            ),
5483            ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
5484            ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
5485        ];
5486        for (text, construct) in cases {
5487            for line_length in [12, 20, 30] {
5488                for atomic_spans in [true, false] {
5489                    let options = ReflowOptions {
5490                        line_length,
5491                        atomic_spans,
5492                        ..Default::default()
5493                    };
5494                    let wrapped = reflow_line(text, &options).join("\n");
5495                    assert!(
5496                        wrapped.contains(construct),
5497                        "{construct} was broken at line_length={line_length} \
5498                         atomic_spans={atomic_spans}: {wrapped:?}"
5499                    );
5500                }
5501            }
5502        }
5503    }
5504
5505    #[test]
5506    fn test_overlong_emphasis_with_nested_code_span_wraps() {
5507        // An emphasis span longer than the whole line budget must still wrap,
5508        // even when it contains a nested code span: keeping it atomic would
5509        // leave a line that can never fit.
5510        let options = ReflowOptions {
5511            line_length: 80,
5512            atomic_spans: true,
5513            ..Default::default()
5514        };
5515        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
5516        let lines = reflow_line(text, &options);
5517        assert_eq!(
5518            lines,
5519            vec![
5520                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5521                "characters with some `code` inside._",
5522            ]
5523        );
5524    }
5525
5526    #[test]
5527    fn test_overlong_emphasis_with_nested_strong_wraps() {
5528        // Same for a nested strong span. The nested span itself stays whole.
5529        let options = ReflowOptions {
5530            line_length: 80,
5531            atomic_spans: true,
5532            ..Default::default()
5533        };
5534        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
5535        let lines = reflow_line(text, &options);
5536        assert_eq!(
5537            lines,
5538            vec![
5539                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
5540                "characters with some **bold** inside._",
5541            ]
5542        );
5543    }
5544
5545    #[test]
5546    fn test_overlong_doubly_nested_span_wraps() {
5547        // The whole content of the outer span is a single nested emphasis span.
5548        // Holding a nested span whole regardless of length left no break point
5549        // anywhere inside, so the line could never be wrapped and MD013 reported
5550        // a violation its own fixer refused to touch.
5551        let options = ReflowOptions {
5552            line_length: 80,
5553            atomic_spans: true,
5554            ..Default::default()
5555        };
5556        let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
5557        for (open, close) in [
5558            ("***", "***"),
5559            ("___", "___"),
5560            ("**_", "_**"),
5561            ("*__", "__*"),
5562            ("**~~", "~~**"),
5563        ] {
5564            let text = format!("{open}{body}{close}");
5565            assert!(text.len() > options.line_length, "case must start over budget");
5566            let lines = reflow_line(&text, &options);
5567            assert!(
5568                lines.len() > 1,
5569                "{open}...{close} should wrap but stayed on one line: {lines:?}"
5570            );
5571            assert!(
5572                lines.iter().all(|line| line.len() <= options.line_length),
5573                "{open}...{close} left a line over the budget: {lines:?}"
5574            );
5575            assert_eq!(
5576                lines.join(" "),
5577                text,
5578                "{open}...{close} wrapping must only replace a space with a newline"
5579            );
5580        }
5581    }
5582
5583    #[test]
5584    fn test_overlong_span_with_stray_marker_stays_whole() {
5585        // A `*` that belongs to no well-formed span means the content is not
5586        // fully modelled. Breaking at these spaces would put `* ` at the start
5587        // of a line, turning literal text into a list item.
5588        let options = ReflowOptions {
5589            line_length: 40,
5590            atomic_spans: true,
5591            ..Default::default()
5592        };
5593        let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
5594        let lines = reflow_line(text, &options);
5595        assert_eq!(lines, vec![text], "stray marker must keep the span whole");
5596    }
5597
5598    #[test]
5599    fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
5600        // A reference-style link only looks like a link once the document's
5601        // definitions are in scope, so the span's own parse sees plain text and
5602        // used to break inside the label. The top level holds these atomic, and
5603        // an inner span has to agree or `fmt` splits a link in one context and
5604        // not the other.
5605        let options = ReflowOptions {
5606            line_length: 30,
5607            atomic_spans: true,
5608            defined_references: Some(HashSet::from([
5609                "ref".to_string(),
5610                // A bare `[text]` is a link only when its own label is defined.
5611                "one two three four five six seven".to_string(),
5612            ])),
5613            ..Default::default()
5614        };
5615        for (text, link) in [
5616            (
5617                "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
5618                "[one two three four five six seven][ref]",
5619            ),
5620            (
5621                "**alpha [one two three four five six seven][ref] beta gamma delta**",
5622                "[one two three four five six seven][ref]",
5623            ),
5624            (
5625                "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
5626                "![one two three four five six seven][ref]",
5627            ),
5628            (
5629                "_**alpha [one two three four five six seven][] beta gamma delta**_",
5630                "[one two three four five six seven][]",
5631            ),
5632            (
5633                "_**alpha [one two three four five six seven] beta gamma delta**_",
5634                "[one two three four five six seven]",
5635            ),
5636        ] {
5637            let lines = reflow_line(text, &options);
5638            assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5639            assert!(
5640                lines.iter().any(|line| line.contains(link)),
5641                "{link} must stay on one line: {lines:?}"
5642            );
5643            assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5644        }
5645    }
5646
5647    #[test]
5648    fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
5649        // A bare `[text]` is only a link when its label is defined. With the
5650        // definitions in scope and no match, it is literal prose and breaks like
5651        // any other words, exactly as the top level treats it.
5652        let options = ReflowOptions {
5653            line_length: 30,
5654            atomic_spans: true,
5655            defined_references: Some(HashSet::new()),
5656            ..Default::default()
5657        };
5658        let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
5659        let lines = reflow_line(text, &options);
5660        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5661        assert!(
5662            !lines
5663                .iter()
5664                .any(|line| line.contains("[one two three four five six seven]")),
5665            "an undefined shortcut is prose and should break: {lines:?}"
5666        );
5667        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5668    }
5669
5670    #[test]
5671    fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
5672        // A MkDocs/kramdown attr list carries structural interior whitespace, so
5673        // splitting it rewrites the attributes. The top level holds it whole; an
5674        // inner span has to agree. Only when the flavor is enabled.
5675        let attr = "{.highlight key=\"a b c\"}";
5676        let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
5677        let options = ReflowOptions {
5678            line_length: 20,
5679            atomic_spans: true,
5680            attr_lists: true,
5681            ..Default::default()
5682        };
5683        let lines = reflow_line(&text, &options);
5684        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
5685        assert!(
5686            lines.iter().any(|line| line.contains(attr)),
5687            "attr list must stay on one line: {lines:?}"
5688        );
5689        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5690
5691        // With the flavor off, the same braces are literal prose and break like
5692        // any other words, exactly as the top level treats them.
5693        let plain = ReflowOptions {
5694            attr_lists: false,
5695            ..options
5696        };
5697        let lines = reflow_line(&text, &plain);
5698        assert!(
5699            !lines.iter().any(|line| line.contains(attr)),
5700            "without the flavor the braces are prose and should break: {lines:?}"
5701        );
5702        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
5703    }
5704
5705    #[test]
5706    fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
5707        // Interior whitespace in a code span is literal, so a break inside one
5708        // would rewrite the code. The nested span is a single unbreakable unit
5709        // and its interior survives byte-for-byte.
5710        let options = ReflowOptions {
5711            line_length: 30,
5712            atomic_spans: true,
5713            ..Default::default()
5714        };
5715        let text = "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa_";
5716        let lines = reflow_line(text, &options);
5717        assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
5718        assert!(
5719            lines.iter().any(|line| line.contains("`a  b`")),
5720            "nested code span must stay whole with its interior spaces: {lines:?}"
5721        );
5722        for line in &lines {
5723            assert_eq!(
5724                line.matches('`').count() % 2,
5725                0,
5726                "no line may contain half a code span: {line:?}"
5727            );
5728        }
5729    }
5730
5731    #[test]
5732    fn test_definition_list_marker_does_not_start_line() {
5733        let options = ReflowOptions {
5734            line_length: 20,
5735            ..Default::default()
5736        };
5737        // Wrap should not start a line with ": "
5738        let lines = reflow_line("This is a term and : definition here.", &options);
5739        for line in &lines {
5740            assert!(
5741                !line.trim_start().starts_with(": "),
5742                "Wrapped line should not start with definition marker: {line}"
5743            );
5744        }
5745    }
5746
5747    #[test]
5748    fn test_div_marker_does_not_start_line() {
5749        let options = ReflowOptions {
5750            line_length: 20,
5751            ..Default::default()
5752        };
5753        // Wrap should not start a line with ":::"
5754        let lines = reflow_line("This is some text with ::: class marker.", &options);
5755        for line in &lines {
5756            assert!(
5757                !line.trim_start().starts_with(":::"),
5758                "Wrapped line should not start with div marker: {line}"
5759            );
5760        }
5761    }
5762}