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