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