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