Skip to main content

rumdl_lib/utils/
text_reflow.rs

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