Skip to main content

rumdl_lib/utils/
text_reflow.rs

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