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