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