Skip to main content

rumdl_lib/utils/
text_reflow.rs

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