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::is_definition_list_item;
8use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
9use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
10use crate::utils::regex_cache::{
11    DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
12    HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
13};
14use crate::utils::sentence_utils::{
15    get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_quote, is_opening_quote,
16    text_ends_with_abbreviation,
17};
18use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22/// Length calculation mode for reflow
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25    /// Count Unicode characters (grapheme clusters)
26    Chars,
27    /// Count visual display width (CJK = 2 columns, emoji = 2, etc.)
28    #[default]
29    Visual,
30    /// Count raw bytes
31    Bytes,
32}
33
34/// Calculate the display length of a string based on the length mode
35fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36    match mode {
37        ReflowLengthMode::Chars => s.chars().count(),
38        ReflowLengthMode::Visual => s.width(),
39        ReflowLengthMode::Bytes => s.len(),
40    }
41}
42
43/// Whitespace characters whose whole purpose is to forbid a line break:
44/// no-break space (U+00A0), narrow no-break space (U+202F), and figure
45/// space (U+2007).
46fn is_non_breaking_space(c: char) -> bool {
47    matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50/// Whitespace on which reflow may break and rejoin lines. Non-breaking
51/// spaces are excluded: they stay inside the surrounding token so they
52/// survive reflow byte-for-byte and never become a wrap point (e.g. the
53/// French `mot\u{00A0}:` pair or a `10\u{00A0}000` thousands separator).
54fn is_breakable_whitespace(c: char) -> bool {
55    c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58/// Split text into wrappable tokens on breakable whitespace only.
59fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60    text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63/// Whether an inline code span's content can be word-wrapped without altering it.
64///
65/// Interior whitespace in code spans is literal. Word-splitting collapses a run
66/// of whitespace to a single space and cannot represent tabs, so wrapping would
67/// corrupt content like `a    b` (four spaces) into `a b`. Only wrap when every
68/// breakable-whitespace separator is already a single plain space; a lone
69/// leading/trailing space still round-trips (CommonMark normalizes it and the
70/// marker-padding path re-adds it for backtick-adjacent content).
71fn code_span_wraps_losslessly(content: &str) -> bool {
72    let mut prev_ws = false;
73    for c in content.chars() {
74        let ws = is_breakable_whitespace(c);
75        if ws && (prev_ws || c != ' ') {
76            return false;
77        }
78        prev_ws = ws;
79    }
80    true
81}
82
83/// How the inline structure nested inside a span's content constrains where a
84/// line break may land.
85struct NestedStructure {
86    /// Ranges a break may never land inside, merged into outermost,
87    /// non-overlapping ranges. The whitespace in a code span is literal, and
88    /// the whitespace in a link destination or an HTML tag is structural, so
89    /// replacing it with a newline rewrites the document. A link, image or
90    /// attr list is held whole beyond that too, matching how the top level
91    /// treats one.
92    atomic: Vec<(usize, usize)>,
93    /// The delimiter runs of nested emphasis, strong and strikethrough spans.
94    /// These marker characters belong to a well-formed span, so they do not
95    /// force the whole span to be kept whole, but they are not break points
96    /// either: the prose between them breaks at whitespace as usual.
97    markers: Vec<(usize, usize)>,
98    /// Every link, image, wikilink and footnote reference the parse recognised,
99    /// nested ones included, sorted by start. Where `atomic` folds a construct
100    /// into the one enclosing it, this keeps each one's own start, so a sentence
101    /// opener can be walked into a link whose text begins with an image.
102    links: Vec<(usize, usize)>,
103}
104
105/// An emphasis, strong or strikethrough span whose end has not been seen yet.
106struct OpenSpan {
107    /// The span's full range, delimiters included.
108    span: (usize, usize),
109    /// Bounds of the content found inside it so far. What falls outside these
110    /// but inside `span` is the delimiter run.
111    content: Option<(usize, usize)>,
112}
113
114/// Record an event as content of every enclosing span still open, widening the
115/// bounds that separate a span's delimiters from what sits between them.
116fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
117    for open_span in open.iter_mut() {
118        if start >= open_span.span.0 && end <= open_span.span.1 {
119            open_span.content = Some(match open_span.content {
120                Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
121                None => (start, end),
122            });
123        }
124    }
125}
126
127fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
128    // A nested construct is reported alongside its parent, so any range
129    // starting at or before the current end is already covered by it.
130    ranges.sort_unstable();
131    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
132    for (start, end) in ranges {
133        match merged.last_mut() {
134            Some(last) if start <= last.1 => last.1 = last.1.max(end),
135            _ => merged.push((start, end)),
136        }
137    }
138    merged
139}
140
141/// Classify the inline constructs nested inside a span's content.
142fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
143    let mut options = Options::empty();
144    options.insert(Options::ENABLE_STRIKETHROUGH);
145
146    let mut atomic: Vec<(usize, usize)> = Vec::new();
147    let mut markers: Vec<(usize, usize)> = Vec::new();
148    let mut links: Vec<(usize, usize)> = Vec::new();
149    // Emphasis-like spans whose end has not been seen yet, each with the bounds
150    // of the content found inside it so far.
151    let mut open: Vec<OpenSpan> = Vec::new();
152
153    for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
154        let (start, end) = (range.start, range.end);
155        // An `End` repeats the range its `Start` already contributed, and the
156        // one closing a span covers that span whole, which would swallow its
157        // own delimiters.
158        if !matches!(event, Event::End(_)) {
159            note_span_content(&mut open, start, end);
160        }
161        match event {
162            Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
163                atomic.push((start, end));
164                links.push((start, end));
165            }
166            Event::Code(_) | Event::InlineHtml(_) => {
167                atomic.push((start, end));
168            }
169            Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
170                open.push(OpenSpan {
171                    span: (start, end),
172                    content: None,
173                });
174            }
175            Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
176                if let Some(OpenSpan {
177                    span: (span_start, span_end),
178                    content,
179                }) = open.pop()
180                {
181                    match content {
182                        // Whatever sits outside the content is the delimiter run.
183                        // Its length is read off the parse rather than assumed,
184                        // since `~x~` and `~~x~~` are both strikethrough.
185                        Some((content_start, content_end)) => {
186                            markers.push((span_start, content_start));
187                            markers.push((content_end, span_end));
188                        }
189                        // Nothing inside to anchor the delimiters against, so
190                        // keep the span whole rather than guess where they end.
191                        None => atomic.push((span_start, span_end)),
192                    }
193                }
194            }
195            _ => {}
196        }
197    }
198
199    // Reference-style links and images (`[text][ref]`, `[text][]`, `[text]`) and
200    // footnote references need the document's definitions to be recognised, which
201    // the parse above has no access to. Reusing the walk the top level runs
202    // keeps a link atomic under exactly the same conditions wherever it appears.
203    // Nested constructs come along too: the reference image inside
204    // `[![alt][img]](url)` is what that link's sentence opens with.
205    for span in all_link_spans(content, defined_references) {
206        atomic.push((span.start, span.end));
207        links.push((span.start, span.end));
208    }
209
210    // Constructs pulldown does not model, but that `parse_elements` holds
211    // atomic at the top level. Only those that can contain whitespace matter
212    // here: an emoji shortcode or HTML entity has no break point inside it.
213    for found in WIKI_LINK_REGEX.find_iter(content) {
214        atomic.push((found.start(), found.end()));
215        links.push((found.start(), found.end()));
216    }
217    for found in HUGO_SHORTCODE_REGEX
218        .find_iter(content)
219        .chain(DISPLAY_MATH_REGEX.find_iter(content))
220    {
221        atomic.push((found.start(), found.end()));
222    }
223    let mut from = 0;
224    while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(content, from) {
225        atomic.push((found.start(), found.end()));
226        from = found.end();
227    }
228
229    // A MkDocs/kramdown attr list (`{.class key="value"}`) holds interior
230    // whitespace that is structural, so breaking inside one rewrites it. The
231    // pattern anchors on `{`, so a non-overlapping sweep finds the same units
232    // the top level does. Only when the flavor is enabled: otherwise `{a b}` is
233    // literal prose and breaks like any other words, matching the top level.
234    if attr_lists {
235        for found in ATTR_LIST_PATTERN.find_iter(content) {
236            atomic.push((found.start(), found.end()));
237        }
238    }
239
240    // Both parses report an outermost inline link, so the same range can arrive
241    // twice; a nested one arrives once, from the parse without definitions.
242    links.sort_unstable();
243    links.dedup();
244
245    NestedStructure {
246        atomic: merge_ranges(atomic),
247        markers: merge_ranges(markers),
248        links,
249    }
250}
251
252/// Split an emphasis, strong or strikethrough span's content into the units
253/// that may be placed on separate lines, or `None` when the span has to stay
254/// atomic.
255///
256/// Breaking such a span at whitespace is safe: emphasis carries across a soft
257/// line break, and a newline is whitespace just like the space it replaces, so
258/// every delimiter keeps its flanking classification. Two things are not safe,
259/// and this rules them out:
260///
261/// - A break inside a code span, link, image or HTML tag. Each is one
262///   unbreakable unit, matching how they are already held atomic at the top
263///   level, because the whitespace in one is literal or structural.
264/// - A marker character that belongs to no well-formed nested span: a stray or
265///   backslash-escaped `` ` ``, `*`, `_` or `~`. The content's structure is then
266///   not fully modelled, so the span is kept whole rather than broken on a guess.
267///   This matters: breaking `**a * b**` at its spaces would put a literal `*` at
268///   the start of a line, turning it into a list item.
269///
270/// A nested emphasis, strong or strikethrough span is not one of those. The
271/// whitespace inside it is ordinary prose whitespace, so it breaks like any
272/// other, and only its delimiter runs are held together with the words they
273/// flank.
274fn breakable_units<'a>(
275    content: &'a str,
276    defined_references: Option<&HashSet<String>>,
277    attr_lists: bool,
278) -> Option<Vec<&'a str>> {
279    // Plain prose cannot hold a nested construct, so every whitespace run is a
280    // break point and the parse below can be skipped.
281    if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
282        return Some(split_breakable_words(content).collect());
283    }
284
285    let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
286
287    let mut units = Vec::new();
288    let mut unit_start = None;
289    let mut next_atomic = 0;
290    let mut next_marker = 0;
291    for (offset, ch) in content.char_indices() {
292        while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
293            next_atomic += 1;
294        }
295        if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
296            // Inside an atomic construct: never a break point, and its markers
297            // are accounted for.
298            if unit_start.is_none() {
299                unit_start = Some(offset);
300            }
301            continue;
302        }
303        while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
304            next_marker += 1;
305        }
306        if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
307            return None;
308        }
309        if is_breakable_whitespace(ch) {
310            if let Some(start) = unit_start.take() {
311                units.push(&content[start..offset]);
312            }
313        } else if unit_start.is_none() {
314            unit_start = Some(offset);
315        }
316    }
317    if let Some(start) = unit_start {
318        units.push(&content[start..]);
319    }
320    Some(units)
321}
322
323/// Options for reflowing text
324#[derive(Clone)]
325pub struct ReflowOptions {
326    /// Target line length
327    pub line_length: usize,
328    /// Whether to break on sentence boundaries when possible
329    pub break_on_sentences: bool,
330    /// Whether to preserve existing line breaks in paragraphs
331    pub preserve_breaks: bool,
332    /// Whether to enforce one sentence per line
333    pub sentence_per_line: bool,
334    /// Whether to use semantic line breaks (cascading split strategy)
335    pub semantic_line_breaks: bool,
336    /// Custom abbreviations for sentence detection
337    /// Periods are optional - both "Dr" and "Dr." work the same
338    /// Custom abbreviations are always added to the built-in defaults
339    pub abbreviations: Option<Vec<String>>,
340    /// How to measure string length for line-length comparisons
341    pub length_mode: ReflowLengthMode,
342    /// Whether to treat {#id .class key="value"} as atomic (unsplittable) elements.
343    /// Enabled for MkDocs and Kramdown flavors.
344    pub attr_lists: bool,
345    /// Whether to treat MyST inline roles (`` {role}`content` ``) as atomic
346    /// (unsplittable) elements. Enabled for the MyST flavor so the colon inside
347    /// `{domain:role}` is never used as a clause-break point.
348    pub myst_roles: bool,
349    /// Whether to require uppercase after periods for sentence detection.
350    /// When true (default), only "word. Capital" is a sentence boundary.
351    /// When false, "word. lowercase" is also treated as a sentence boundary.
352    /// Does not affect ! and ? which are always treated as sentence boundaries.
353    pub require_sentence_capital: bool,
354    /// Cap list continuation indent to this value when set.
355    /// Used by mkdocs flavor where continuation is always 4 spaces
356    /// regardless of checkbox markers.
357    pub max_list_continuation_indent: Option<usize>,
358    /// Defined reference labels for the surrounding document, used to decide
359    /// whether a bare shortcut reference (`[text]`) is a real link (kept atomic
360    /// during reflow) or literal bracketed prose (wrapped like normal text).
361    ///
362    /// `None` means no reference information is available: every shortcut is
363    /// treated as atomic. This is the safe default - it never splits a real
364    /// link, at the cost of also not wrapping literal bracketed prose.
365    ///
366    /// `Some(set)` enables definition-aware behavior: a shortcut is atomic only
367    /// when its normalized label (see [`normalize_reference_label`]) is in the
368    /// set. Full and collapsed reference links and reference images are always
369    /// atomic regardless, because their `][ref]` / `[]` syntax is an explicit
370    /// link signal that does not depend on a definition being in scope.
371    pub defined_references: Option<HashSet<String>>,
372    /// Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow.
373    /// When true (default), these spans are treated as atomic units.
374    /// When false, they can be wrapped word-by-word like normal text.
375    pub atomic_spans: bool,
376    /// Which of the checker's line-length exemptions reflow mirrors when it
377    /// measures a line. Empty measures the markdown as written.
378    pub length_exemptions: LengthExemptions,
379}
380
381/// The line-length exemptions MD013's check applies, as far as reflow can mirror
382/// them.
383///
384/// The checker tests each one against the budget separately, so a line is
385/// forgiven when either reduced length fits, never when the two savings together
386/// would fit. Reflow therefore has to keep them apart too: see
387/// [`LineWidth`].
388#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
389pub struct LengthExemptions {
390    /// An inline `[text](url)` costs `[text]` and an inline `![alt](url)` costs
391    /// `![alt]`. Reference, collapsed and shortcut forms are never exempt.
392    pub link_urls: bool,
393    /// An inline code span costs nothing, because it cannot be wrapped.
394    pub code_spans: bool,
395}
396
397impl LengthExemptions {
398    /// Whether any exemption is active. When none is, every width below reduces
399    /// to the plain source width and the whole mechanism is inert.
400    fn any(&self) -> bool {
401        self.link_urls || self.code_spans
402    }
403}
404
405impl Default for ReflowOptions {
406    fn default() -> Self {
407        Self {
408            line_length: 80,
409            break_on_sentences: true,
410            preserve_breaks: false,
411            sentence_per_line: false,
412            semantic_line_breaks: false,
413            abbreviations: None,
414            length_mode: ReflowLengthMode::default(),
415            attr_lists: false,
416            myst_roles: false,
417            require_sentence_capital: true,
418            max_list_continuation_indent: None,
419            defined_references: None,
420            atomic_spans: true,
421            length_exemptions: LengthExemptions::default(),
422        }
423    }
424}
425
426/// A line's width under each exemption the checker applies independently.
427///
428/// The checker forgives a line when the link-exempt length fits *or* the
429/// code-exempt length fits, so the width that decides whether reflow may stop is
430/// the smaller of the two, never one total with both savings taken out. Adding
431/// widths is component-wise, which is what makes it usable as a running total.
432#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
433struct LineWidth {
434    /// Width with inline link and image destinations discounted.
435    link_exempt: usize,
436    /// Width with inline code spans discounted.
437    code_exempt: usize,
438}
439
440impl LineWidth {
441    /// A span of text that no exemption touches, so both components are its
442    /// full width.
443    fn plain(width: usize) -> Self {
444        Self {
445            link_exempt: width,
446            code_exempt: width,
447        }
448    }
449
450    /// The width the checker measures this line at: whichever exemption helps
451    /// more.
452    fn effective(self) -> usize {
453        self.link_exempt.min(self.code_exempt)
454    }
455
456    fn fits(self, line_length: usize) -> bool {
457        self.effective() <= line_length
458    }
459
460    /// Whether nothing has been accumulated. Only an empty string measures zero
461    /// under both exemptions: the cheapest an exempt link can be is `[]`, and a
462    /// code span still costs its full width against the link exemption.
463    fn is_empty(self) -> bool {
464        self.link_exempt == 0 && self.code_exempt == 0
465    }
466}
467
468impl std::ops::Add for LineWidth {
469    type Output = Self;
470
471    fn add(self, other: Self) -> Self {
472        Self {
473            link_exempt: self.link_exempt + other.link_exempt,
474            code_exempt: self.code_exempt + other.code_exempt,
475        }
476    }
477}
478
479impl std::ops::AddAssign for LineWidth {
480    fn add_assign(&mut self, other: Self) {
481        *self = *self + other;
482    }
483}
484
485/// Normalize a reference label for definition matching: collapse internal
486/// whitespace runs to a single space, trim, and lowercase (CommonMark-style
487/// label matching). Both the defined labels and the shortcut references checked
488/// against them are run through this function, so matching is case- and
489/// whitespace-insensitive. Biasing toward matching keeps a real shortcut link
490/// atomic even when its use and definition differ only in case or whitespace.
491pub fn normalize_reference_label(label: &str) -> String {
492    label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
493}
494
495/// If `chars` starts at `start` with one or more consecutive footnote
496/// references (`[^label]`, matching the same `[a-zA-Z0-9_-]+` label grammar as
497/// `FOOTNOTE_REF` in `mkdocs_footnotes.rs`), return the position just past the
498/// last one. Returns `None` if `start` is not the beginning of a footnote
499/// reference, so a bare `[1]` or `[text]` never matches.
500fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
501    let mut pos = start;
502    let mut found = false;
503
504    loop {
505        if chars.get(pos) != Some(&'[') || chars.get(pos + 1) != Some(&'^') {
506            break;
507        }
508        let label_start = pos + 2;
509        let mut label_end = label_start;
510        while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
511            label_end += 1;
512        }
513        if label_end == label_start || chars.get(label_end) != Some(&']') {
514            break;
515        }
516        pos = label_end + 1;
517        found = true;
518    }
519
520    found.then_some(pos)
521}
522
523/// Byte offset of each char in the text `chars` was collected from, with the
524/// text's byte length as a final entry.
525fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
526    let mut offsets = Vec::with_capacity(chars.len() + 1);
527    let mut offset = 0;
528    for c in chars {
529        offsets.push(offset);
530        offset += c.len_utf8();
531    }
532    offsets.push(offset);
533    offsets
534}
535
536/// A text being split into sentences, with the views the boundary check reads
537/// alongside it: its chars, each char's byte offset (plus the text's length as
538/// a final entry), and the `links` list of its [`NestedStructure`], the
539/// link-like constructs a sentence may open with, nested ones included.
540struct SentenceText<'a> {
541    text: &'a str,
542    chars: &'a [char],
543    char_offsets: &'a [usize],
544    links: &'a [(usize, usize)],
545}
546
547impl SentenceText<'_> {
548    /// Char index just past the link, image, wikilink or footnote reference
549    /// that starts at `chars[pos]`, or `None` when no such construct starts
550    /// there. The construct is one the parse behind `links` recognised, so a
551    /// bracket the parse reads as text (`[Smith 2020]` with no such reference
552    /// defined, `[text](unterminated`) opens nothing here either, and one
553    /// nested in another (`[![alt](img)](url)`) is found at its own start. An
554    /// Obsidian embed `![[note]]` is known to the parse from its `[[`, so the
555    /// range starts one char after its `!`.
556    fn link_end_at(&self, pos: usize) -> Option<usize> {
557        let range_start = match self.chars.get(pos) {
558            Some('[') => pos,
559            Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
560                Some(end) => return Some(end),
561                None => pos + 1,
562            },
563            _ => return None,
564        };
565        self.link_range_end_at(range_start)
566    }
567
568    /// Char index just past the link-like construct that starts at `chars[pos]`.
569    fn link_range_end_at(&self, pos: usize) -> Option<usize> {
570        let start = self.char_offsets[pos];
571        let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
572        let end = self.links[idx].1;
573        Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
574    }
575}
576
577/// Detect if a character position is a sentence boundary
578/// Based on the approach from github.com/JoshuaKGoldberg/sentences-per-line
579/// Supports both ASCII punctuation (. ! ?) and CJK punctuation (。 ! ?)
580fn is_sentence_boundary(
581    st: &SentenceText<'_>,
582    pos: usize,
583    abbreviations: &HashSet<String>,
584    require_sentence_capital: bool,
585) -> bool {
586    let SentenceText { text, chars, .. } = *st;
587    if pos + 1 >= chars.len() {
588        return false;
589    }
590    let byte_offset_after_punct = st.char_offsets[pos + 1];
591
592    let c = chars[pos];
593    let next_char = chars[pos + 1];
594
595    // Check for CJK sentence-ending punctuation (。, !, ?)
596    // CJK punctuation doesn't require space or uppercase after it
597    if is_cjk_sentence_ending(c) {
598        // Skip any trailing emphasis/strikethrough markers
599        let mut after_punct_pos = pos + 1;
600        while after_punct_pos < chars.len()
601            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
602        {
603            after_punct_pos += 1;
604        }
605
606        // Skip whitespace
607        while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
608            after_punct_pos += 1;
609        }
610
611        // Check if we have more content (any non-whitespace)
612        if after_punct_pos >= chars.len() {
613            return false;
614        }
615
616        // Skip leading emphasis/strikethrough markers
617        while after_punct_pos < chars.len()
618            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
619        {
620            after_punct_pos += 1;
621        }
622
623        if after_punct_pos >= chars.len() {
624            return false;
625        }
626
627        // For CJK, we accept any character as the start of the next sentence
628        // (no uppercase requirement, since CJK doesn't have case)
629        return true;
630    }
631
632    // Check for ASCII sentence-ending punctuation
633    if c != '.' && c != '!' && c != '?' {
634        return false;
635    }
636
637    // A terminator immediately followed by a closing quote sits inside the
638    // quotation, not after it.
639    let inside_quotation = is_closing_quote(next_char);
640
641    // Must be followed by space, closing quote, or emphasis/strikethrough marker followed by space
642    let (_space_pos, after_space_pos) = if next_char == ' ' {
643        // Normal case: punctuation followed by space
644        (pos + 1, pos + 2)
645    } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
646        // Sentence ends with quote - check what follows the quote
647        if chars[pos + 2] == ' ' {
648            // Just quote followed by space: 'sentence." '
649            (pos + 2, pos + 3)
650        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
651            // Quote followed by emphasis: 'sentence."* '
652            (pos + 3, pos + 4)
653        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
654            && pos + 4 < chars.len()
655            && chars[pos + 3] == chars[pos + 2]
656            && chars[pos + 4] == ' '
657        {
658            // Quote followed by bold: 'sentence."** '
659            (pos + 4, pos + 5)
660        } else {
661            return false;
662        }
663    } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
664        // Sentence ends with emphasis: "sentence.* " or "sentence._ "
665        (pos + 2, pos + 3)
666    } else if (next_char == '*' || next_char == '_')
667        && pos + 3 < chars.len()
668        && chars[pos + 2] == next_char
669        && chars[pos + 3] == ' '
670    {
671        // Sentence ends with bold: "sentence.** " or "sentence.__ "
672        (pos + 3, pos + 4)
673    } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
674        // Sentence ends with strikethrough: "sentence.~~ "
675        (pos + 3, pos + 4)
676    } else if next_char == '[' {
677        // Sentence ends with one or more footnote references glued directly to
678        // the punctuation, e.g. "sentence.[^1]" or "sentence.[^1][^2]". A bare
679        // `[1]` or `[text]` doesn't match `footnote_refs_end` and falls through
680        // to `return false` below, since that's link/citation-like text, not
681        // footnote syntax.
682        match footnote_refs_end(chars, pos + 1) {
683            Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
684            _ => return false,
685        }
686    } else {
687        return false;
688    };
689
690    // Skip all whitespace after the space to find the start of the next sentence
691    let mut next_char_pos = after_space_pos;
692    while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
693        next_char_pos += 1;
694    }
695
696    // Check if we reached the end of the string
697    if next_char_pos >= chars.len() {
698        return false;
699    }
700
701    // Skip leading emphasis/strikethrough markers, opening quotes and the
702    // opener of a link, image or wikilink to find the actual first letter: a
703    // sentence that starts with `[Link text](url)` starts with its text. A
704    // bracket the parse reads as text is not skipped, so a citation like
705    // `[Smith 2020]` or a footnote label opens no sentence.
706    let mut first_letter_pos = next_char_pos;
707    while first_letter_pos < chars.len() {
708        let ch = chars[first_letter_pos];
709        if let Some(end) = st.link_end_at(first_letter_pos) {
710            first_letter_pos += link_opener_len(chars, first_letter_pos, end);
711        } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
712            first_letter_pos += 1;
713        } else {
714            break;
715        }
716    }
717
718    // Check if we reached the end after skipping emphasis
719    if first_letter_pos >= chars.len() {
720        return false;
721    }
722
723    let first_char = chars[first_letter_pos];
724
725    // A bare ! or ? ends a sentence unambiguously, unlike a period, which also
726    // ends abbreviations, decimals and initials. Inside a quotation it is
727    // ambiguous again: the question can belong to the quoted phrase rather than
728    // to the sentence carrying it, as in `A "Is this a test?" guide`. A
729    // lowercase word after the closing quote means that sentence continues.
730    if c == '!' || c == '?' {
731        return !inside_quotation || !require_sentence_capital || first_char.is_uppercase() || is_cjk_char(first_char);
732    }
733
734    // Period-specific checks: periods are ambiguous (abbreviations, decimals, initials)
735    // so we apply additional guards before accepting a sentence boundary.
736
737    if pos > 0 {
738        // Check for common abbreviations
739        if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
740            return false;
741        }
742
743        // Check for decimal numbers (e.g., "3.14 is pi")
744        if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
745            return false;
746        }
747
748        // Check for single-letter initials (e.g., "J. K. Rowling")
749        // A single uppercase letter before the period preceded by whitespace or start
750        // is likely an initial, not a sentence ending.
751        if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
752            return false;
753        }
754    }
755
756    // In strict mode, require uppercase or CJK to start the next sentence after a period.
757    // In relaxed mode, accept any alphanumeric character.
758    if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
759        return false;
760    }
761
762    true
763}
764
765/// Length in chars of the opener of the link, image, wikilink or footnote
766/// reference occupying `chars[pos..end]`: the part before the text a reader
767/// sees. `[` opens a link and `![` an image. A wikilink's `[[` opener runs
768/// past its alias pipe, since `[[target|display]]` shows `display`; the pipe
769/// is looked for inside the construct only, before its closing `]]`.
770fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
771    let open = if chars[pos] == '!' { pos + 1 } else { pos };
772    let body = open + 1;
773    if chars.get(body) != Some(&'[') {
774        return body - pos;
775    }
776    let body = body + 1;
777    let alias = chars[body..end.saturating_sub(2).max(body)]
778        .iter()
779        .position(|&c| c == '|')
780        .map_or(body, |p| body + p + 1);
781    alias - pos
782}
783
784/// Split text into sentences.
785///
786/// `defined_references` is the document's set of normalized reference labels,
787/// which decides whether a bare `[text]` is a link (its label is defined) or
788/// prose; `None` means the definitions are unknown and every shortcut is held
789/// to be a link, so no real one is ever split. See
790/// [`ReflowOptions::defined_references`].
791pub fn split_into_sentences(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<String> {
792    let abbreviations = get_abbreviations(&None);
793    split_into_sentences_with_set(text, &abbreviations, true, None, defined_references)
794}
795
796/// Internal function to split text into sentences with a pre-computed abbreviations set
797/// Use this when calling multiple times in a loop to avoid repeatedly computing the set
798///
799/// `appended_span_start` is the byte offset at which an inline span the caller has
800/// just appended begins, for callers that assemble a line one element at a time. A
801/// sentence ending inside a span takes the closing marker with it, so a delimiter
802/// run right after the punctuation joins the sentence that ends there. At that one
803/// offset the run is the appended span's opening marker instead, and carrying it
804/// back would leave the span with nothing to open it.
805fn split_into_sentences_with_set(
806    text: &str,
807    abbreviations: &HashSet<String>,
808    require_sentence_capital: bool,
809    appended_span_start: Option<usize>,
810    defined_references: Option<&HashSet<String>>,
811) -> Vec<String> {
812    let char_vec: Vec<char> = text.chars().collect();
813    let char_offsets = char_byte_offsets(&char_vec);
814
815    // The constructs a boundary must not fall inside, sorted and non-overlapping,
816    // and the link-like ones a sentence may open with.
817    let NestedStructure { atomic, links, .. } = sentence_structure(text, defined_references);
818    let mut atomic_it = atomic.iter().peekable();
819    let st = SentenceText {
820        text,
821        chars: &char_vec,
822        char_offsets: &char_offsets,
823        links: &links,
824    };
825
826    let mut sentences = Vec::new();
827    let mut current_sentence = String::new();
828    let mut pos = 0;
829
830    while pos < char_vec.len() {
831        let c = char_vec[pos];
832        current_sentence.push(c);
833
834        let byte_idx = char_offsets[pos];
835
836        // Advance past every atomic range the current char start has left behind.
837        while let Some(&&(_, end)) = atomic_it.peek() {
838            if end <= byte_idx {
839                atomic_it.next();
840            } else {
841                break;
842            }
843        }
844
845        // True if the current character position falls inside an atomic construct.
846        let in_atomic = atomic_it
847            .peek()
848            .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
849
850        if !in_atomic && is_sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
851            // Consume any trailing footnote references glued to the punctuation
852            if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) {
853                while pos + 1 < end_pos {
854                    pos += 1;
855                    current_sentence.push(char_vec[pos]);
856                }
857            }
858
859            // Consume any trailing emphasis/strikethrough markers and quotes
860            while pos + 1 < char_vec.len() {
861                let next = char_vec[pos + 1];
862                if matches!(next, '*' | '_' | '~') && Some(char_offsets[pos + 1]) == appended_span_start {
863                    break;
864                }
865                if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
866                    pos += 1;
867                    current_sentence.push(char_vec[pos]);
868                } else {
869                    break;
870                }
871            }
872
873            // Consume the space after the sentence
874            if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' {
875                pos += 1; // skip space (not pushed to current_sentence)
876            }
877
878            sentences.push(current_sentence.trim().to_string());
879            current_sentence.clear();
880        }
881
882        pos += 1;
883    }
884
885    // Add any remaining text as the last sentence
886    if !current_sentence.trim().is_empty() {
887        sentences.push(current_sentence.trim().to_string());
888    }
889    sentences
890}
891
892/// The inline structure of a text being split into sentences: the byte ranges
893/// a boundary must not fall inside, and the link-like constructs a sentence
894/// may open with.
895///
896/// A link's text, destination and title, an image's alt text, a wikilink's
897/// target, a math span, an HTML tag's attributes and a code span each hold text
898/// that reads like prose to the boundary check (`[First. Second](url)`) but is
899/// one construct to the renderer, so a line break inside it rewrites the
900/// document rather than its layout. These are the ranges `parse_elements`
901/// holds atomic, computed here from the raw text so that the check counting a
902/// line's sentences and the reflow splitting them agree on where a sentence
903/// can end. A bare `[text]` is a link only when `defined_references` holds
904/// its label, prose otherwise; without the definitions (`None`) it is held
905/// atomic wherever it appears, which keeps a real shortcut link whole at the
906/// price of leaving a bracketed prose aside on one line.
907fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
908    // Every construct that can hold whitespace, and every link-like construct,
909    // opens with one of these; plain prose skips the parse entirely.
910    if !text.contains(['`', '[', '<', '$']) {
911        return NestedStructure {
912            atomic: Vec::new(),
913            markers: Vec::new(),
914            links: Vec::new(),
915        };
916    }
917    nested_structure(text, defined_references, false)
918}
919
920/// Check if a line is a horizontal rule (---, ___, ***)
921fn is_horizontal_rule(line: &str) -> bool {
922    if line.len() < 3 {
923        return false;
924    }
925
926    // Line must consist only of a single marker char (-, _, or *) plus spaces,
927    // with at least 3 markers. Scan chars directly to avoid allocating a Vec.
928    let mut chars = line.chars();
929    let Some(first_char) = chars.next() else {
930        return false;
931    };
932    if first_char != '-' && first_char != '_' && first_char != '*' {
933        return false;
934    }
935
936    let mut non_space_count = 1usize; // first_char is a marker
937    for c in chars {
938        if c == ' ' {
939            continue;
940        }
941        if c != first_char {
942            return false;
943        }
944        non_space_count += 1;
945    }
946    non_space_count >= 3
947}
948
949/// Check if a line is a numbered list item (e.g., "1. ", "10. ")
950fn is_numbered_list_item(line: &str) -> bool {
951    let mut chars = line.chars();
952
953    // Must start with a digit
954    if !chars.next().is_some_and(char::is_numeric) {
955        return false;
956    }
957
958    // Can have more digits
959    while let Some(c) = chars.next() {
960        if c == '.' {
961            // After period, must have a space (consistent with list marker extraction)
962            // "2019." alone is NOT treated as a list item to avoid false positives
963            return chars.next() == Some(' ');
964        }
965        if !c.is_numeric() {
966            return false;
967        }
968    }
969
970    false
971}
972
973/// Check if a trimmed line is an unordered list item (-, *, + followed by space)
974fn is_unordered_list_marker(s: &str) -> bool {
975    matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
976        && !is_horizontal_rule(s)
977        && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
978}
979
980/// Shared structural checks for block boundary detection.
981/// Checks elements that only depend on the trimmed line content.
982fn is_block_boundary_core(trimmed: &str) -> bool {
983    trimmed.is_empty()
984        || trimmed.starts_with('#')
985        || trimmed.starts_with("```")
986        || trimmed.starts_with("~~~")
987        || trimmed.starts_with('>')
988        || (trimmed.starts_with('[') && trimmed.contains("]:"))
989        || is_horizontal_rule(trimmed)
990        || is_unordered_list_marker(trimmed)
991        || is_numbered_list_item(trimmed)
992        || is_definition_list_item(trimmed)
993        || trimmed.starts_with(":::")
994}
995
996/// Check if a trimmed line starts a new structural block element.
997/// Used for paragraph boundary detection in `reflow_markdown()`.
998fn is_block_boundary(trimmed: &str) -> bool {
999    is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1000}
1001
1002/// Check if a line starts a new structural block for paragraph boundary detection
1003/// in `reflow_paragraph_at_line()`. Extends the core checks with indented code blocks
1004/// (≥4 spaces) and table row detection via `is_potential_table_row`.
1005fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1006    is_block_boundary_core(trimmed)
1007        || calculate_indentation_width_default(line) >= 4
1008        || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1009}
1010
1011/// Check if a line ends with a hard break (either two spaces or backslash)
1012///
1013/// CommonMark supports two formats for hard line breaks:
1014/// 1. Two or more trailing spaces
1015/// 2. A backslash at the end of the line
1016fn has_hard_break(line: &str) -> bool {
1017    let line = line.strip_suffix('\r').unwrap_or(line);
1018    line.ends_with("  ") || line.ends_with('\\')
1019}
1020
1021/// Check if text ends with sentence-terminating punctuation (. ! ?)
1022fn ends_with_sentence_punct(text: &str) -> bool {
1023    text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1024}
1025
1026/// Trim trailing whitespace while preserving hard breaks (two trailing spaces or backslash)
1027///
1028/// Hard breaks in Markdown can be indicated by:
1029/// 1. Two trailing spaces before a newline (traditional)
1030/// 2. A backslash at the end of the line (mdformat style)
1031fn trim_preserving_hard_break(s: &str) -> String {
1032    // Strip trailing \r from CRLF line endings first to handle Windows files
1033    let s = s.strip_suffix('\r').unwrap_or(s);
1034
1035    // Check for backslash hard break (mdformat style)
1036    if s.ends_with('\\') {
1037        // Preserve the backslash exactly as-is
1038        return s.to_string();
1039    }
1040
1041    // Check if there are at least 2 trailing spaces (traditional hard break)
1042    if s.ends_with("  ") {
1043        // Find the position where non-space content ends
1044        let content_end = s.trim_end().len();
1045        if content_end == 0 {
1046            // String is all whitespace
1047            return String::new();
1048        }
1049        // Preserve exactly 2 trailing spaces for hard break
1050        format!("{}  ", &s[..content_end])
1051    } else {
1052        // No hard break, just trim all trailing whitespace
1053        s.trim_end().to_string()
1054    }
1055}
1056
1057/// Parse markdown elements using the appropriate parser based on options.
1058fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
1059    parse_markdown_elements_inner(
1060        text,
1061        options.attr_lists,
1062        options.myst_roles,
1063        options.defined_references.as_ref(),
1064    )
1065}
1066
1067/// Reflow a line, falling back to the input when the result would not preserve it.
1068///
1069/// Reflow redistributes whitespace: it decides where lines break, never which
1070/// characters a paragraph contains. So the sequence of non-whitespace characters
1071/// is invariant across a correct reflow, and any difference means the reflow
1072/// dropped, duplicated, reordered, or invented content. Returning the input
1073/// unchanged in that case costs a paragraph that stays unwrapped; the alternative
1074/// is writing corrupted prose into the user's file. A caller comparing its
1075/// replacement against the original then sees no change and reports nothing.
1076pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
1077    let reflowed = reflow_line_unchecked(line, options);
1078    if preserves_content(line, &reflowed) {
1079        reflowed
1080    } else {
1081        vec![line.to_string()]
1082    }
1083}
1084
1085/// Whether `reflowed` still holds `original`'s text: the same non-whitespace
1086/// characters in the same order, with every word boundary intact.
1087///
1088/// Reflow may add a boundary the input did not have, since wrapping a script
1089/// that writes without spaces has to break somewhere. Removing one is different:
1090/// it glues two words into a word the author never wrote.
1091fn preserves_content(original: &str, reflowed: &[String]) -> bool {
1092    let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
1093    let (reflowed_text, reflowed_breaks) =
1094        visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
1095
1096    original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
1097}
1098
1099/// The non-whitespace characters of `text`, and for each interior run of
1100/// whitespace, how many characters precede it.
1101fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
1102    let mut visible = String::new();
1103    let mut breaks = Vec::new();
1104    let mut count = 0usize;
1105    let mut pending_break = false;
1106
1107    for c in text {
1108        if c.is_whitespace() {
1109            pending_break = count > 0;
1110        } else {
1111            if pending_break {
1112                breaks.push(count);
1113                pending_break = false;
1114            }
1115            visible.push(c);
1116            count += 1;
1117        }
1118    }
1119
1120    (visible, breaks)
1121}
1122
1123/// Whether every value in `subset` appears in `superset`. Both are ascending.
1124fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
1125    let mut candidates = superset.iter();
1126    subset
1127        .iter()
1128        .all(|wanted| candidates.by_ref().any(|found| found == wanted))
1129}
1130
1131fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
1132    // For sentence-per-line mode, always process regardless of length
1133    if options.sentence_per_line {
1134        let elements = parse_elements(line, options);
1135        return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
1136    }
1137
1138    // For semantic line breaks mode, use cascading split strategy
1139    if options.semantic_line_breaks {
1140        let elements = parse_elements(line, options);
1141        return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
1142    }
1143
1144    // Quick check: if line is already short enough or no wrapping requested, return as-is
1145    // line_length = 0 means no wrapping (unlimited line length)
1146    if options.line_length == 0 || line_fits(line, options) {
1147        return vec![line.to_string()];
1148    }
1149
1150    // Parse the markdown to identify elements
1151    let elements = parse_elements(line, options);
1152
1153    // Reflow the elements into lines
1154    merge_block_construct_continuations(reflow_elements(&elements, options))
1155}
1156
1157/// Represents a piece of content in the markdown
1158#[derive(Debug, Clone)]
1159enum Element {
1160    /// Plain text that can be wrapped
1161    Text(String),
1162    /// A complete markdown inline link [text](url)
1163    Link(String),
1164    /// A complete markdown reference link [text][ref]
1165    ReferenceLink(String),
1166    /// A complete markdown empty reference link [text][]
1167    EmptyReferenceLink(String),
1168    /// A complete markdown shortcut reference link [ref]
1169    ShortcutReference(String),
1170    /// A complete markdown inline image ![alt](url)
1171    InlineImage(String),
1172    /// A complete markdown reference image ![alt][ref]
1173    ReferenceImage(String),
1174    /// A complete markdown empty reference image ![alt][]
1175    EmptyReferenceImage(String),
1176    /// A clickable image badge
1177    LinkedImage(String),
1178    /// Footnote reference [^note]
1179    FootnoteReference(String),
1180    /// Strikethrough text ~~text~~ or ~text~ (GFM allows one or two tildes)
1181    Strikethrough {
1182        content: String,
1183        /// True if the original used a double-tilde (~~) marker, false for a single tilde (~)
1184        double: bool,
1185    },
1186    /// Wiki-style link [[wiki]] or [[wiki|text]]
1187    WikiLink(String),
1188    /// Inline math $math$
1189    InlineMath(String),
1190    /// Display math $$math$$
1191    DisplayMath(String),
1192    /// Emoji shortcode :emoji:
1193    EmojiShortcode(String),
1194    /// Autolink <https://...> or <mailto:...> or <user@domain.com>
1195    Autolink(String),
1196    /// HTML tag <tag> or </tag> or <tag/>
1197    HtmlTag(String),
1198    /// HTML entity &nbsp; or &#123;
1199    HtmlEntity(String),
1200    /// Hugo/Go template shortcode {{< ... >}} or {{% ... %}}
1201    HugoShortcode(String),
1202    /// MkDocs/kramdown attribute list {#id .class key="value"}
1203    AttrList(String),
1204    /// MyST inline role `` {role}`content` `` (or `` {domain:role}`content` ``).
1205    /// Stored as the raw matched text and rendered verbatim so it round-trips
1206    /// exactly; treated as atomic so it is never split mid-role.
1207    MystRole(String),
1208    /// Inline code `code`
1209    Code { content: String, marker: String },
1210    /// Bold text **text** or __text__
1211    Bold {
1212        content: String,
1213        /// True if underscore markers (__), false for asterisks (**)
1214        underscore: bool,
1215    },
1216    /// Italic text *text* or _text_
1217    Italic {
1218        content: String,
1219        /// True if underscore marker (_), false for asterisk (*)
1220        underscore: bool,
1221    },
1222}
1223
1224impl Element {
1225    /// Whether the element's source form opens with `[` or `![`: a link,
1226    /// image, wikilink, footnote or shortcut reference. What the sentence
1227    /// splitter makes of that bracket decides whether the element can start a
1228    /// sentence, so the reflow defers to the splitter for these.
1229    fn opens_with_bracket(&self) -> bool {
1230        matches!(
1231            self,
1232            Element::Link(_)
1233                | Element::ReferenceLink(_)
1234                | Element::EmptyReferenceLink(_)
1235                | Element::ShortcutReference(_)
1236                | Element::FootnoteReference(_)
1237                | Element::InlineImage(_)
1238                | Element::ReferenceImage(_)
1239                | Element::EmptyReferenceImage(_)
1240                | Element::LinkedImage(_)
1241                | Element::WikiLink(_)
1242        )
1243    }
1244}
1245
1246impl std::fmt::Display for Element {
1247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1248        match self {
1249            Element::Text(s) => write!(f, "{s}"),
1250            Element::Link(s) => write!(f, "{s}"),
1251            Element::ReferenceLink(s) => write!(f, "{s}"),
1252            Element::EmptyReferenceLink(s) => write!(f, "{s}"),
1253            Element::ShortcutReference(s) => write!(f, "{s}"),
1254            Element::InlineImage(s) => write!(f, "{s}"),
1255            Element::ReferenceImage(s) => write!(f, "{s}"),
1256            Element::EmptyReferenceImage(s) => write!(f, "{s}"),
1257            Element::LinkedImage(s) => write!(f, "{s}"),
1258            Element::FootnoteReference(s) => write!(f, "{s}"),
1259            Element::Strikethrough { content, double } => {
1260                let marker = if *double { "~~" } else { "~" };
1261                write!(f, "{marker}{content}{marker}")
1262            }
1263            Element::WikiLink(s) => write!(f, "[[{s}]]"),
1264            Element::InlineMath(s) => write!(f, "${s}$"),
1265            Element::DisplayMath(s) => write!(f, "$${s}$$"),
1266            Element::EmojiShortcode(s) => write!(f, ":{s}:"),
1267            Element::Autolink(s) => write!(f, "{s}"),
1268            Element::HtmlTag(s) => write!(f, "{s}"),
1269            Element::HtmlEntity(s) => write!(f, "{s}"),
1270            Element::HugoShortcode(s) => write!(f, "{s}"),
1271            Element::AttrList(s) => write!(f, "{s}"),
1272            Element::MystRole(s) => write!(f, "{s}"),
1273            Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
1274            Element::Bold { content, underscore } => {
1275                if *underscore {
1276                    write!(f, "__{content}__")
1277                } else {
1278                    write!(f, "**{content}**")
1279                }
1280            }
1281            Element::Italic { content, underscore } => {
1282                if *underscore {
1283                    write!(f, "_{content}_")
1284                } else {
1285                    write!(f, "*{content}*")
1286                }
1287            }
1288        }
1289    }
1290}
1291
1292impl Element {
1293    fn display_len(&self, mode: ReflowLengthMode) -> usize {
1294        match self {
1295            Element::Text(s)
1296            | Element::Link(s)
1297            | Element::ReferenceLink(s)
1298            | Element::EmptyReferenceLink(s)
1299            | Element::ShortcutReference(s)
1300            | Element::InlineImage(s)
1301            | Element::ReferenceImage(s)
1302            | Element::EmptyReferenceImage(s)
1303            | Element::LinkedImage(s)
1304            | Element::FootnoteReference(s)
1305            | Element::Autolink(s)
1306            | Element::HtmlTag(s)
1307            | Element::HtmlEntity(s)
1308            | Element::HugoShortcode(s)
1309            | Element::AttrList(s)
1310            | Element::MystRole(s) => display_len(s, mode),
1311            Element::WikiLink(s) => display_len(s, mode) + 4,
1312            Element::InlineMath(s) => display_len(s, mode) + 2,
1313            Element::DisplayMath(s) => display_len(s, mode) + 4,
1314            Element::EmojiShortcode(s) => display_len(s, mode) + 2,
1315            Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
1316            Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
1317            Element::Bold { content, .. } => display_len(content, mode) + 4,
1318            Element::Italic { content, .. } => display_len(content, mode) + 2,
1319        }
1320    }
1321
1322    /// The width the checker measures this element at, under each exemption.
1323    ///
1324    /// An inline link costs `[text]` and an inline image `![alt]`, exactly as
1325    /// `MD013`'s check computes them; a code span costs nothing. Every other
1326    /// element, reference and shortcut link forms included, costs its full
1327    /// width, because the check does not exempt those either.
1328    ///
1329    /// An element whose text cannot be delimited is charged in full. Charging
1330    /// too much only makes reflow wrap a line the check would have forgiven;
1331    /// charging too little would leave a line the check reports.
1332    fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
1333        let full = self.display_len(mode);
1334        let mut width = LineWidth::plain(full);
1335        match self {
1336            Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
1337                if let Some(text) = bracketed_text(s, 0) {
1338                    width.link_exempt = (2 + display_len(text, mode)).min(full);
1339                }
1340            }
1341            Element::InlineImage(s) if exemptions.link_urls => {
1342                if let Some(alt) = bracketed_text(s, 1) {
1343                    width.link_exempt = (3 + display_len(alt, mode)).min(full);
1344                }
1345            }
1346            Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
1347            _ => {}
1348        }
1349        width
1350    }
1351}
1352
1353/// The source between the `[` at byte `open` and its matching `]`.
1354///
1355/// Bracket matching follows the document parser: a nested `[` raises the depth,
1356/// a backslash-escaped bracket is literal, and a bracket inside a code span is
1357/// literal too, so `[a \] b](url)` and ``[a `]` b](url)`` are each one link.
1358/// Returns `None` when `open` is not a `[` or the bracket never closes.
1359fn bracketed_text(s: &str, open: usize) -> Option<&str> {
1360    let bytes = s.as_bytes();
1361    if bytes.get(open) != Some(&b'[') {
1362        return None;
1363    }
1364    let mut depth = 0usize;
1365    let mut in_code_span = false;
1366    let mut escaped = false;
1367    for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
1368        if escaped {
1369            escaped = false;
1370            continue;
1371        }
1372        match byte {
1373            b'\\' => escaped = true,
1374            b'`' => in_code_span = !in_code_span,
1375            b'[' if !in_code_span => depth += 1,
1376            b']' if !in_code_span => match depth.checked_sub(1) {
1377                Some(next) => depth = next,
1378                None => return s.get(open + 1..i),
1379            },
1380            _ => {}
1381        }
1382    }
1383    None
1384}
1385
1386/// An emphasis or formatting span parsed by pulldown-cmark
1387#[derive(Debug, Clone)]
1388struct EmphasisSpan {
1389    /// Byte offset where the emphasis starts (including markers)
1390    start: usize,
1391    /// Byte offset where the emphasis ends (after closing markers)
1392    end: usize,
1393    /// The content inside the emphasis markers
1394    content: String,
1395    /// Whether this is strong (bold) emphasis
1396    is_strong: bool,
1397    /// Whether this is strikethrough (~~text~~)
1398    is_strikethrough: bool,
1399    /// Whether the original used underscore markers (for emphasis only)
1400    uses_underscore: bool,
1401    /// For strikethrough spans, whether the original used a double-tilde (~~)
1402    /// marker rather than a single tilde (~). Meaningless for other spans.
1403    strikethrough_double: bool,
1404}
1405
1406/// Extract emphasis and strikethrough spans from text using pulldown-cmark
1407///
1408/// This provides CommonMark-compliant emphasis parsing, correctly handling:
1409/// - Nested emphasis like `*text **bold** more*`
1410/// - Left/right flanking delimiter rules
1411/// - Underscore vs asterisk markers
1412/// - GFM strikethrough (~~text~~)
1413///
1414/// Returns spans sorted by start position.
1415fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
1416    // If neither marker is present, skip the parser entirely.
1417    let has_emphasis = text.contains(['*', '_', '~']);
1418    let has_code = text.contains('`');
1419    if !has_emphasis && !has_code {
1420        return (Vec::new(), Vec::new());
1421    }
1422
1423    let mut emphasis_spans = Vec::new();
1424    let mut code_spans = Vec::new();
1425
1426    let mut options = Options::empty();
1427    if has_emphasis {
1428        options.insert(Options::ENABLE_STRIKETHROUGH);
1429    }
1430
1431    // Stacks to track nested formatting with their start positions
1432    let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); // (start_byte, uses_underscore)
1433    let mut strong_stack: Vec<(usize, bool)> = Vec::new();
1434    let mut strikethrough_stack: Vec<usize> = Vec::new();
1435
1436    let parser = Parser::new_ext(text, options).into_offset_iter();
1437
1438    for (event, range) in parser {
1439        match event {
1440            Event::Code(_) => {
1441                code_spans.push(CodeSpan {
1442                    start: range.start,
1443                    end: range.end,
1444                });
1445            }
1446            Event::Start(Tag::Emphasis) => {
1447                // Check if this uses underscore by looking at the original text
1448                let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
1449                emphasis_stack.push((range.start, uses_underscore));
1450            }
1451            Event::End(TagEnd::Emphasis) => {
1452                if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
1453                    let content_start = start_byte + 1;
1454                    let content_end = range.end - 1;
1455                    if content_end > content_start
1456                        && let Some(content) = text.get(content_start..content_end)
1457                    {
1458                        emphasis_spans.push(EmphasisSpan {
1459                            start: start_byte,
1460                            end: range.end,
1461                            content: content.to_string(),
1462                            is_strong: false,
1463                            is_strikethrough: false,
1464                            uses_underscore,
1465                            strikethrough_double: false,
1466                        });
1467                    }
1468                }
1469            }
1470            Event::Start(Tag::Strong) => {
1471                let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
1472                strong_stack.push((range.start, uses_underscore));
1473            }
1474            Event::End(TagEnd::Strong) => {
1475                if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
1476                    let content_start = start_byte + 2;
1477                    let content_end = range.end - 2;
1478                    if content_end > content_start
1479                        && let Some(content) = text.get(content_start..content_end)
1480                    {
1481                        emphasis_spans.push(EmphasisSpan {
1482                            start: start_byte,
1483                            end: range.end,
1484                            content: content.to_string(),
1485                            is_strong: true,
1486                            is_strikethrough: false,
1487                            uses_underscore,
1488                            strikethrough_double: false,
1489                        });
1490                    }
1491                }
1492            }
1493            Event::Start(Tag::Strikethrough) => {
1494                strikethrough_stack.push(range.start);
1495            }
1496            Event::End(TagEnd::Strikethrough) => {
1497                if let Some(start_byte) = strikethrough_stack.pop() {
1498                    let double = text.get(start_byte..start_byte + 2) == Some("~~");
1499                    let marker_len = if double { 2 } else { 1 };
1500                    let content_start = start_byte + marker_len;
1501                    let content_end = range.end - marker_len;
1502                    if content_end > content_start
1503                        && let Some(content) = text.get(content_start..content_end)
1504                    {
1505                        emphasis_spans.push(EmphasisSpan {
1506                            start: start_byte,
1507                            end: range.end,
1508                            content: content.to_string(),
1509                            is_strong: false,
1510                            is_strikethrough: true,
1511                            uses_underscore: false,
1512                            strikethrough_double: double,
1513                        });
1514                    }
1515                }
1516            }
1517            _ => {}
1518        }
1519    }
1520
1521    emphasis_spans.sort_by_key(|s| s.start);
1522    (emphasis_spans, code_spans)
1523}
1524
1525#[derive(Debug, Clone)]
1526struct CodeSpan {
1527    start: usize,
1528    end: usize,
1529}
1530
1531#[derive(Debug, Clone)]
1532struct LinkSpan {
1533    start: usize,
1534    end: usize,
1535    link_type: Option<LinkType>,
1536    is_image: bool,
1537    is_footnote: bool,
1538    /// How many links or images enclose this one. The image in
1539    /// `[![alt](img)](url)` sits at depth 1.
1540    depth: usize,
1541}
1542
1543/// The outermost links, images and footnote references in `text`, sorted by
1544/// start. The top level holds each of these whole, so a construct nested in
1545/// another is covered by the one enclosing it.
1546fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1547    let mut spans = all_link_spans(text, defined_references);
1548    spans.retain(|span| span.depth == 0);
1549    spans
1550}
1551
1552/// Every link, image and footnote reference in `text`, nested ones included,
1553/// sorted by start.
1554fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
1555    // Links, images, and footnote references all open with `[`; skip the
1556    // parser entirely without one.
1557    if !text.contains('[') {
1558        return Vec::new();
1559    }
1560
1561    let mut spans = Vec::new();
1562    let mut options = Options::empty();
1563    options.insert(Options::ENABLE_FOOTNOTES);
1564
1565    // Reflow parses each paragraph in isolation, so the document's reference
1566    // definitions are never in scope. Without a broken-link callback,
1567    // pulldown-cmark would emit reference-style links (`[text][ref]`,
1568    // `[text][]`, `[text]`, `![alt][ref]`) as plain text, and reflow would wrap
1569    // their text mid-link. Resolving an unresolved reference to a dummy
1570    // destination makes pulldown emit the full link span so reflow treats it as
1571    // an atomic unit; the destination is unused because the element is rebuilt
1572    // verbatim from the source bytes.
1573    //
1574    // Full and collapsed references and reference images carry explicit
1575    // `][ref]` / `[]` syntax, so they are always resolved (atomic). A bare
1576    // shortcut `[text]` is ambiguous: it is only a real link when its label is
1577    // actually defined. With `Some(defined_references)` an undefined shortcut is
1578    // left unresolved (returns `None`) so it reflows as literal prose; with
1579    // `None` (no reference info) every shortcut stays atomic, which never splits
1580    // a real link.
1581    let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
1582        // The callback reports the syntactic reference type (`Shortcut` for a
1583        // bare `[text]`); the eventual emitted tag carries the `*Unknown`
1584        // variant. Only a bare shortcut is ambiguous - full and collapsed
1585        // references fall through and stay atomic.
1586        let atomic = match link.link_type {
1587            LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
1588                Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
1589                None => true,
1590            },
1591            _ => true,
1592        };
1593        atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
1594    };
1595    let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
1596    let mut stack = Vec::new();
1597
1598    for (event, range) in parser {
1599        match event {
1600            Event::Start(Tag::Link { link_type, .. }) => {
1601                stack.push((range.start, Some(link_type), false));
1602            }
1603            Event::Start(Tag::Image { link_type, .. }) => {
1604                stack.push((range.start, Some(link_type), true));
1605            }
1606            Event::End(TagEnd::Link | TagEnd::Image) => {
1607                if let Some((start_byte, link_type, is_image)) = stack.pop() {
1608                    spans.push(LinkSpan {
1609                        start: start_byte,
1610                        end: range.end,
1611                        link_type,
1612                        is_image,
1613                        is_footnote: false,
1614                        depth: stack.len(),
1615                    });
1616                }
1617            }
1618            Event::FootnoteReference(_) => {
1619                spans.push(LinkSpan {
1620                    start: range.start,
1621                    end: range.end,
1622                    link_type: None,
1623                    is_image: false,
1624                    is_footnote: true,
1625                    depth: stack.len(),
1626                });
1627            }
1628            _ => {}
1629        }
1630    }
1631
1632    spans.sort_by_key(|s| s.start);
1633    spans
1634}
1635
1636/// If `text` starts with a MyST inline role (`` {name}`content` `` or
1637/// `` {domain:role}`content` ``), return the byte length of the whole role unit.
1638///
1639/// Mirrors the grammar in `lint_context::flavor_detection::detect_myst_role_ranges`:
1640/// a `{`, a name starting with an ASCII letter or `_` and continuing with
1641/// alphanumerics / `-` / `_` / `:` / `.`, a closing `}`, then a balanced inline
1642/// code span using one or more backticks. Returns `None` when any part is missing.
1643fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
1644    let bytes = text.as_bytes();
1645    if bytes.first() != Some(&b'{') {
1646        return None;
1647    }
1648
1649    // Role name.
1650    let mut j = 1;
1651    match bytes.get(j) {
1652        Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
1653        _ => return None,
1654    }
1655    while let Some(&b) = bytes.get(j) {
1656        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
1657            j += 1;
1658        } else {
1659            break;
1660        }
1661    }
1662    if bytes.get(j) != Some(&b'}') {
1663        return None;
1664    }
1665    j += 1; // past '}'
1666
1667    // Must be immediately followed by an inline code span.
1668    let code_span_start = absolute_pos + j;
1669    if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
1670        let span = &code_spans[idx];
1671        let code_span_len = span.end - span.start;
1672        return Some(j + code_span_len);
1673    }
1674
1675    None
1676}
1677
1678/// Byte length of an inline-math span (`$math$`) starting at the very
1679/// beginning of `s`, if one starts there.
1680///
1681/// Mirrors INLINE_MATH_REGEX (`(?<!\$)\$(?!\$)([^\$]+)\$(?!\$)`) with the
1682/// leading lookbehind dropped: callers probe only at a slice start, where
1683/// the lookbehind passes vacuously.
1684fn inline_math_len_at_start(s: &str) -> Option<usize> {
1685    let bytes = s.as_bytes();
1686    // Opening `$` not followed by another `$` (that would be display math).
1687    if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
1688        return None;
1689    }
1690    // Content is `[^$]+`: everything up to the closing `$`. It is non-empty
1691    // whenever a closing `$` exists, because the byte at index 1 is not `$`.
1692    let close = 1 + s[1..].find('$')?;
1693    // Closing `$` not followed by another `$`.
1694    if bytes.get(close + 1) == Some(&b'$') {
1695        return None;
1696    }
1697    Some(close + 1)
1698}
1699
1700/// Absolute byte offsets of a cached pattern match within the full input text.
1701#[derive(Clone, Copy, Debug)]
1702struct PatternMatch {
1703    start: usize,
1704    end: usize,
1705}
1706
1707/// Lazily-computed earliest match of one pattern within the unparsed suffix.
1708///
1709/// `parse_markdown_elements_inner` probes every pattern on every loop
1710/// iteration; re-running each search against the whole remaining suffix made
1711/// pathological inputs quadratic. The cache keeps the previous result as
1712/// absolute offsets: until the parse cursor moves past a cached match, that
1713/// match is still the earliest one, so the search is skipped.
1714///
1715/// This is sound only for patterns whose match at a given position does not
1716/// depend on where the searched slice starts (no `^`, no lookbehind): for
1717/// those, a cached miss stays a miss and a cached hit stays the earliest hit
1718/// as the cursor advances. A start-sensitive pattern needs a dedicated probe
1719/// at the cursor first (see the inline-math call site).
1720#[derive(Clone, Copy)]
1721enum PatternCache {
1722    Unsearched,
1723    NotFound,
1724    Found(PatternMatch),
1725}
1726
1727impl PatternCache {
1728    /// Returns the earliest match at or after `cursor` as offsets relative to
1729    /// `remaining` (the unparsed suffix starting at `cursor`), re-running
1730    /// `find` on the suffix only when the cached result no longer applies.
1731    fn earliest_in(
1732        &mut self,
1733        remaining: &str,
1734        cursor: usize,
1735        find: impl FnOnce(&str) -> Option<(usize, usize)>,
1736    ) -> Option<(usize, usize)> {
1737        let stale = match self {
1738            PatternCache::Found(pm) => pm.start < cursor,
1739            PatternCache::NotFound => false,
1740            PatternCache::Unsearched => true,
1741        };
1742        if stale {
1743            *self = match find(remaining) {
1744                Some((start, end)) => PatternCache::Found(PatternMatch {
1745                    start: cursor + start,
1746                    end: cursor + end,
1747                }),
1748                None => PatternCache::NotFound,
1749            };
1750        }
1751        match self {
1752            PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
1753            _ => None,
1754        }
1755    }
1756}
1757
1758/// Parse markdown elements from text preserving the raw syntax.
1759///
1760/// Detection order is critical:
1761/// 1. Linked images [![alt](img)](link) - must be detected first as atomic units
1762/// 2. Inline images ![alt](url) - before links to handle ! prefix
1763/// 3. Reference images ![alt][ref] - before reference links
1764/// 4. Inline links [text](url) - before reference links
1765/// 5. Reference links [text][ref] - before shortcut references
1766/// 6. Shortcut reference links [ref] - detected last to avoid false positives
1767/// 7. Other elements (code, bold, italic, MyST roles, etc.) - processed normally
1768fn parse_markdown_elements_inner(
1769    text: &str,
1770    attr_lists: bool,
1771    myst_roles: bool,
1772    defined_references: Option<&HashSet<String>>,
1773) -> Vec<Element> {
1774    let mut elements = Vec::new();
1775    let mut remaining = text;
1776
1777    // Pre-extract emphasis spans, link spans, and code spans using pulldown-cmark.
1778    // Emphasis and code spans are extracted in a single shared parse to reduce cmark overhead.
1779    // Link spans must run as a separate parse because link resolution (the broken-link
1780    // callback) changes bracket collapses, which shifts delimiter range boundaries.
1781    let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
1782    let link_spans = extract_link_spans(text, defined_references);
1783
1784    // One cache per probed pattern to avoid an O(N^2) worst case on long
1785    // inputs; see PatternCache for the validity rules.
1786    let mut cached_wiki_link = PatternCache::Unsearched;
1787    let mut cached_display_math = PatternCache::Unsearched;
1788    let mut cached_inline_math = PatternCache::Unsearched;
1789    let mut cached_emoji = PatternCache::Unsearched;
1790    let mut cached_html_entity = PatternCache::Unsearched;
1791    let mut cached_hugo_shortcode = PatternCache::Unsearched;
1792    let mut cached_html_tag = PatternCache::Unsearched;
1793    let mut cached_next_curly = PatternCache::Unsearched;
1794
1795    // Cursor indices into the sorted span lists: spans behind the parse cursor
1796    // can never match again, so each list is advanced monotonically instead of
1797    // rescanned from the start on every iteration.
1798    let mut link_span_idx = 0usize;
1799    let mut emphasis_span_idx = 0usize;
1800    let mut code_span_idx = 0usize;
1801
1802    while !remaining.is_empty() {
1803        // Calculate current byte offset in original text
1804        let current_offset = text.len() - remaining.len();
1805        // Find the earliest occurrence of any markdown pattern
1806        // Store (start, end, pattern_name) to unify regex and span-list results
1807        let mut earliest_match: Option<(usize, usize, &str)> = None;
1808
1809        // Find the earliest link span
1810        while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
1811            link_span_idx += 1;
1812        }
1813        let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
1814
1815        if let Some(span) = next_link {
1816            let pos_in_remaining = span.start - current_offset;
1817            if earliest_match
1818                .as_ref()
1819                .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1820            {
1821                let match_end = span.end - current_offset;
1822                earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1823            }
1824        }
1825
1826        // Check for wiki-style links - [[wiki]]
1827        if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
1828            WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1829        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1830        {
1831            earliest_match = Some((start, end, "wiki_link"));
1832        }
1833
1834        // Check for display math first (before inline) - $$math$$
1835        if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
1836            DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1837        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1838        {
1839            earliest_match = Some((start, end, "display_math"));
1840        }
1841
1842        // Check for inline math - $math$
1843        // INLINE_MATH_REGEX opens with the lookbehind `(?<!\$)`, which is
1844        // slice-start-sensitive: at the start of the searched slice there is
1845        // no preceding character, so the lookbehind trivially passes, while
1846        // the cached search, anchored earlier, saw the real `$` predecessor
1847        // and can have rejected the same position. Positions past the cursor
1848        // are unaffected by where the slice starts, so the cache stays valid
1849        // for them; only a match beginning exactly at the cursor can be
1850        // missing from it. When the cursor sits directly after a `$`, probe
1851        // for that one match in place, leaving the cache untouched. (Either
1852        // rescanning the suffix here or storing the probe hit in the cache is
1853        // quadratic on math-heavy inputs: each consumed span would trigger a
1854        // fresh scan of everything that follows.)
1855        let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
1856            inline_math_len_at_start(remaining).map(|len| (0, len))
1857        } else {
1858            None
1859        };
1860        if let Some((start, end)) = inline_math_probe.or_else(|| {
1861            cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
1862                INLINE_MATH_REGEX
1863                    .find(suffix)
1864                    .ok()
1865                    .flatten()
1866                    .map(|m| (m.start(), m.end()))
1867            })
1868        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1869        {
1870            earliest_match = Some((start, end, "inline_math"));
1871        }
1872
1873        // Check for emoji shortcodes - :emoji:
1874        if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
1875            EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1876        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1877        {
1878            earliest_match = Some((start, end, "emoji"));
1879        }
1880
1881        // Check for HTML entities - &nbsp; etc
1882        if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
1883            HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1884        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1885        {
1886            earliest_match = Some((start, end, "html_entity"));
1887        }
1888
1889        // Check for Hugo shortcodes - {{< ... >}} or {{% ... %}}
1890        // Must be checked before other patterns to avoid false sentence breaks
1891        if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
1892            HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
1893        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1894        {
1895            earliest_match = Some((start, end, "hugo_shortcode"));
1896        }
1897
1898        // Check for HTML tags - <tag> </tag> <tag/>
1899        // But exclude autolinks like <https://...> or <mailto:...> or email
1900        // autolinks <user@domain.com>: those are left for link_span handling.
1901        // The search skips past autolinks instead of giving up so the cache
1902        // lands on the first real tag; bailing out at an autolink would re-run
1903        // this scan from the same spot on every iteration.
1904        if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
1905            let mut from = 0;
1906            while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
1907                let (tag_start, tag_end) = (from + m.start(), from + m.end());
1908                let tag = &suffix[tag_start..tag_end];
1909                // Autolink starting with a protocol or mailto:?
1910                let is_url_autolink = tag.starts_with("<http://")
1911                    || tag.starts_with("<https://")
1912                    || tag.starts_with("<mailto:")
1913                    || tag.starts_with("<ftp://")
1914                    || tag.starts_with("<ftps://");
1915                // Email autolink (per CommonMark spec: <local@domain.tld>)?
1916                // Use centralized EMAIL_PATTERN for consistency with MD034 and other rules
1917                let is_email_autolink = {
1918                    let content = tag.trim_start_matches('<').trim_end_matches('>');
1919                    EMAIL_PATTERN.is_match(content)
1920                };
1921                if is_url_autolink || is_email_autolink {
1922                    from = tag_end;
1923                } else {
1924                    return Some((tag_start, tag_end));
1925                }
1926            }
1927            None
1928        }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
1929        {
1930            earliest_match = Some((start, end, "html_tag"));
1931        }
1932
1933        // Find earliest non-link special characters
1934        let mut next_special = remaining.len();
1935        let mut special_type = "";
1936        let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1937        let mut attr_list_len: usize = 0;
1938        let mut myst_role_len: usize = 0;
1939
1940        // Check for code spans using pulldown-cmark pre-extracted spans
1941        while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
1942            code_span_idx += 1;
1943        }
1944        let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
1945        if let Some(span) = next_code_span {
1946            let pos_in_remaining = span.start - current_offset;
1947            if pos_in_remaining < next_special {
1948                next_special = pos_in_remaining;
1949                special_type = "pulldown_code";
1950            }
1951        }
1952
1953        // Position of the next `{`, shared by the MyST-role and attr-list
1954        // probes below
1955        let next_curly_pos = cached_next_curly
1956            .earliest_in(remaining, current_offset, |suffix| {
1957                suffix.find('{').map(|pos| (pos, pos + 1))
1958            })
1959            .map(|(start, _)| start);
1960
1961        // Check for MyST inline roles - {role}`content` (e.g. {cite:p}`ref`).
1962        // Checked before the bare code-span handling so the role's trailing code
1963        // span is absorbed into the atomic role rather than split off, and before
1964        // attr lists since a role's `{` would otherwise be probed as an attr list.
1965        if myst_roles
1966            && let Some(pos) = next_curly_pos
1967            && pos < next_special
1968            && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
1969        {
1970            next_special = pos;
1971            special_type = "myst_role";
1972            myst_role_len = role_len;
1973        }
1974
1975        // Check for MkDocs/kramdown attr lists - {#id .class key="value"}
1976        if attr_lists
1977            && let Some(pos) = next_curly_pos
1978            && pos < next_special
1979            && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1980            && m.start() == 0
1981        {
1982            next_special = pos;
1983            special_type = "attr_list";
1984            attr_list_len = m.end();
1985        }
1986
1987        // Check for emphasis using pulldown-cmark's pre-extracted spans
1988        while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
1989            emphasis_span_idx += 1;
1990        }
1991        if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
1992            let pos_in_remaining = span.start - current_offset;
1993            if pos_in_remaining < next_special {
1994                next_special = pos_in_remaining;
1995                special_type = "pulldown_emphasis";
1996                pulldown_emphasis = Some(span);
1997            }
1998        }
1999
2000        // Determine which pattern to process first
2001        let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2002            pos < next_special
2003        } else {
2004            false
2005        };
2006
2007        if should_process_markdown_link {
2008            let (pos, match_end, pattern_type) = earliest_match.unwrap();
2009
2010            // Add any text before the match
2011            if pos > 0 {
2012                elements.push(Element::Text(remaining[..pos].to_string()));
2013            }
2014
2015            // Process the matched pattern
2016            match pattern_type {
2017                "link_span" => {
2018                    let span = next_link.unwrap();
2019                    let raw_text = remaining[pos..match_end].to_string();
2020                    if span.is_footnote {
2021                        elements.push(Element::FootnoteReference(raw_text));
2022                    } else if span.is_image {
2023                        match span.link_type {
2024                            Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2025                            // `*Unknown` variants are produced when reflow's broken-link
2026                            // callback resolves a reference whose definition is out of scope.
2027                            Some(LinkType::Reference)
2028                            | Some(LinkType::ReferenceUnknown)
2029                            | Some(LinkType::Shortcut)
2030                            | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2031                            Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2032                                elements.push(Element::EmptyReferenceImage(raw_text))
2033                            }
2034                            _ => elements.push(Element::InlineImage(raw_text)),
2035                        }
2036                    } else {
2037                        match span.link_type {
2038                            Some(LinkType::Inline) => {
2039                                if raw_text.starts_with('[') && raw_text.contains("![") {
2040                                    elements.push(Element::LinkedImage(raw_text));
2041                                } else {
2042                                    elements.push(Element::Link(raw_text));
2043                                }
2044                            }
2045                            // `*Unknown` variants are produced when reflow's broken-link
2046                            // callback resolves a reference whose definition is out of scope.
2047                            Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
2048                                elements.push(Element::ReferenceLink(raw_text))
2049                            }
2050                            Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2051                                elements.push(Element::EmptyReferenceLink(raw_text))
2052                            }
2053                            Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
2054                                elements.push(Element::ShortcutReference(raw_text))
2055                            }
2056                            Some(LinkType::Autolink) | Some(LinkType::Email) => {
2057                                elements.push(Element::Autolink(raw_text))
2058                            }
2059                            _ => elements.push(Element::Link(raw_text)),
2060                        }
2061                    }
2062                    remaining = &remaining[match_end..];
2063                }
2064                "wiki_link" => {
2065                    if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
2066                        let content = caps.get(1).map_or("", |m| m.as_str());
2067                        elements.push(Element::WikiLink(content.to_string()));
2068                        remaining = &remaining[match_end..];
2069                    } else {
2070                        elements.push(Element::Text("[[".to_string()));
2071                        remaining = &remaining[2..];
2072                    }
2073                }
2074                "display_math" => {
2075                    if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
2076                        let math = caps.get(1).map_or("", |m| m.as_str());
2077                        elements.push(Element::DisplayMath(math.to_string()));
2078                        remaining = &remaining[match_end..];
2079                    } else {
2080                        elements.push(Element::Text("$$".to_string()));
2081                        remaining = &remaining[2..];
2082                    }
2083                }
2084                "inline_math" => {
2085                    if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
2086                        let math = caps.get(1).map_or("", |m| m.as_str());
2087                        elements.push(Element::InlineMath(math.to_string()));
2088                        remaining = &remaining[match_end..];
2089                    } else {
2090                        elements.push(Element::Text("$".to_string()));
2091                        remaining = &remaining[1..];
2092                    }
2093                }
2094                "emoji" => {
2095                    if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
2096                        let emoji = caps.get(1).map_or("", |m| m.as_str());
2097                        elements.push(Element::EmojiShortcode(emoji.to_string()));
2098                        remaining = &remaining[match_end..];
2099                    } else {
2100                        elements.push(Element::Text(":".to_string()));
2101                        remaining = &remaining[1..];
2102                    }
2103                }
2104                "html_entity" => {
2105                    // HTML entities are captured whole
2106                    elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
2107                    remaining = &remaining[match_end..];
2108                }
2109                "hugo_shortcode" => {
2110                    // Hugo shortcodes are atomic elements - preserve them exactly
2111                    elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
2112                    remaining = &remaining[match_end..];
2113                }
2114                "html_tag" => {
2115                    // HTML tags are captured whole
2116                    elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
2117                    remaining = &remaining[match_end..];
2118                }
2119                _ => unreachable!("unknown pattern type: {}", pattern_type),
2120            }
2121        } else {
2122            // Process non-link special characters
2123
2124            // Add any text before the special character
2125            if next_special > 0 && next_special < remaining.len() {
2126                elements.push(Element::Text(remaining[..next_special].to_string()));
2127                remaining = &remaining[next_special..];
2128            }
2129
2130            // Process the special element
2131            match special_type {
2132                "pulldown_code" => {
2133                    let span = next_code_span.unwrap();
2134                    let span_len = span.end - span.start;
2135                    let code_raw = &remaining[..span_len];
2136                    if let Some((content, marker)) = decompose_code_span(code_raw) {
2137                        elements.push(Element::Code {
2138                            content: content.to_string(),
2139                            marker: marker.to_string(),
2140                        });
2141                    } else {
2142                        elements.push(Element::Text(code_raw.to_string()));
2143                    }
2144                    remaining = &remaining[span_len..];
2145                }
2146                "attr_list" => {
2147                    elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
2148                    remaining = &remaining[attr_list_len..];
2149                }
2150                "myst_role" => {
2151                    elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
2152                    remaining = &remaining[myst_role_len..];
2153                }
2154                "pulldown_emphasis" => {
2155                    // Use pre-extracted emphasis/strikethrough span from pulldown-cmark
2156                    let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
2157                    let span_len = span.end - span.start;
2158                    if span.is_strikethrough {
2159                        elements.push(Element::Strikethrough {
2160                            content: span.content.clone(),
2161                            double: span.strikethrough_double,
2162                        });
2163                    } else if span.is_strong {
2164                        elements.push(Element::Bold {
2165                            content: span.content.clone(),
2166                            underscore: span.uses_underscore,
2167                        });
2168                    } else {
2169                        elements.push(Element::Italic {
2170                            content: span.content.clone(),
2171                            underscore: span.uses_underscore,
2172                        });
2173                    }
2174                    remaining = &remaining[span_len..];
2175                }
2176                _ => {
2177                    // No special elements found, add all remaining text
2178                    elements.push(Element::Text(remaining.to_string()));
2179                    break;
2180                }
2181            }
2182        }
2183    }
2184
2185    // Merge contiguous text elements to clean up the output.
2186    let mut merged_elements = Vec::new();
2187    for el in elements {
2188        match el {
2189            Element::Text(s) => {
2190                if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
2191                    last_s.push_str(&s);
2192                } else {
2193                    merged_elements.push(Element::Text(s));
2194                }
2195            }
2196            other => merged_elements.push(other),
2197        }
2198    }
2199    merged_elements
2200}
2201
2202/// The whitespace the source put in front of the element at `idx`, as reflow
2203/// should re-emit it.
2204///
2205/// A span carries no whitespace of its own, so the gap before it lives at the
2206/// end of the text element preceding it, which the sentence and clause paths
2207/// trim away as they accumulate. Reading the gap back from the source is what
2208/// keeps a standalone `-` from being glued onto the span after it: the
2209/// characters a line happens to end with cannot tell a dash that closes a word
2210/// from one that stands alone, and the same holds for a bracket or paren.
2211///
2212/// A run of breakable whitespace renders as one space and comes back as one. A
2213/// non-breaking space is a character the reader sees, so a gap containing one
2214/// is carried through exactly as written.
2215fn source_gap_before(elements: &[Element], idx: usize) -> &str {
2216    let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
2217        return "";
2218    };
2219
2220    let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
2221    if gap.is_empty() {
2222        ""
2223    } else if gap.contains(is_non_breaking_space) {
2224        gap
2225    } else {
2226        " "
2227    }
2228}
2229
2230/// Open `gap` before the element about to be appended, unless the line has
2231/// nothing for it to follow or already ends with whitespace of its own.
2232fn push_source_gap(current_line: &mut String, gap: &str) {
2233    if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
2234        current_line.push_str(gap);
2235    }
2236}
2237
2238/// True when `text` consists solely of setext-underline or thematic-break
2239/// characters: a run of `=` or `-` (setext underline, any count, no internal
2240/// spaces) or 3+ `-`/`*`/`_` optionally separated by spaces (thematic break).
2241/// A paragraph-continuation line like this converts the previous line into a
2242/// heading or inserts a horizontal rule.
2243fn is_setext_or_thematic(text: &str) -> bool {
2244    let mut marker = 0u8;
2245    let mut count = 0usize;
2246    let mut has_space = false;
2247    for &b in text.as_bytes() {
2248        match b {
2249            b' ' | b'\t' => has_space = true,
2250            b'-' | b'=' | b'*' | b'_' => {
2251                if marker == 0 {
2252                    marker = b;
2253                } else if b != marker {
2254                    return false;
2255                }
2256                count += 1;
2257            }
2258            _ => return false,
2259        }
2260    }
2261    match marker {
2262        b'=' => !has_space,
2263        b'-' => !has_space || count >= 3,
2264        b'*' | b'_' => count >= 3,
2265        _ => false,
2266    }
2267}
2268
2269/// True when `text`, placed at the start of a paragraph-continuation line,
2270/// would be re-parsed as opening a block construct - a list item (`- `, `* `,
2271/// `+ `, `1. `, `1) `), blockquote (`>`), ATX heading (`# `), code fence
2272/// (3+ backticks or tildes), thematic break, setext underline, footnote or
2273/// link-reference definition (`[^note]:`, `[label]: url`), or HTML block
2274/// (`<div>` and the other block-level tags rumdl's parser recognizes).
2275/// Reflow must never start a wrapped line with such content: prose that was
2276/// harmless mid-line becomes real block syntax at line start, silently
2277/// changing the document's structure (a `- ` clause becomes a nested list
2278/// item, a `# ` becomes a heading, a `[ref]: url` turns a dangling reference
2279/// elsewhere in the document into a live link, and so on).
2280fn starts_block_construct(text: &str) -> bool {
2281    let text = text.trim_start();
2282    let bytes = text.as_bytes();
2283    let Some(&first) = bytes.first() else {
2284        return false;
2285    };
2286    let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
2287    match first {
2288        // A blockquote marker needs no following space
2289        b'>' => true,
2290        b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
2291        b'_' | b'=' => is_setext_or_thematic(text),
2292        b':' => is_definition_list_item(text) || text.starts_with(":::"),
2293        b'|' => true,
2294        b'#' => {
2295            let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
2296            hashes <= 6 && marker_then_boundary(hashes)
2297        }
2298        b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
2299        b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
2300        // An ordered list is the one construct here that cannot always
2301        // interrupt a paragraph: it does so only when it is numbered 1 and its
2302        // first item has content. `7. item` and a bare `123456.` are prose to
2303        // the parser, so guarding them would refuse a legal wrap and leave an
2304        // unfixable long line. Leading zeros still make the number 1 (`01.`),
2305        // and a marker is at most 9 digits.
2306        b'0'..=b'9' => {
2307            let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
2308            digits <= 9
2309                && text[..digits].trim_start_matches('0') == "1"
2310                && bytes.len() > digits + 1
2311                && (bytes[digits] == b'.' || bytes[digits] == b')')
2312                && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
2313        }
2314        // Footnote/link-reference definition: `[label]:` anchored at line
2315        // start, meaning the label's own closing bracket is immediately
2316        // followed by a colon ("[ref]: url", "[^1]: note" - but not
2317        // "[a](b) [ref]:", whose first bracket is an inline link). rumdl's
2318        // parser recognizes definitions even on paragraph-continuation lines,
2319        // so hoisting one to line start reclassifies it (and can resolve
2320        // dangling references elsewhere in the document).
2321        b'[' => {
2322            let mut escaped = false;
2323            let mut label_close = None;
2324            for (i, &b) in bytes.iter().enumerate().skip(1) {
2325                if escaped {
2326                    escaped = false;
2327                } else if b == b'\\' {
2328                    escaped = true;
2329                } else if b == b']' {
2330                    label_close = Some(i);
2331                    break;
2332                }
2333            }
2334            label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
2335        }
2336        // Block-level HTML tag per rumdl's parser (shared predicate, so the
2337        // guard cannot drift from what lint_context classifies as a block).
2338        b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
2339        _ => false,
2340    }
2341}
2342
2343/// Merge any reflowed continuation line that would open a block construct back
2344/// into the previous line. This is the safety net behind the per-break-site
2345/// guards: no matter which emitter produced the lines, a wrapped continuation
2346/// must never turn prose into a list item, heading, blockquote, code fence, or
2347/// horizontal rule. The first line keeps its position - it replaces the
2348/// paragraph's original start, where the source already established the
2349/// context. The merged line may exceed the configured width; a long line is
2350/// the correct failure direction, corrupted structure is not.
2351fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
2352    let mut merged: Vec<String> = Vec::with_capacity(lines.len());
2353    for line in lines {
2354        merged.push(line);
2355        // A merge can itself produce an opener: a line holding just `1.` is
2356        // inert on its own, but absorbing a following `[ref]:` turns it into
2357        // `1. [ref]:`, a real list item. Keep folding until the tail is inert.
2358        while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
2359            let last = merged.pop().expect("non-empty");
2360            let prev = merged.last_mut().expect("len > 1");
2361            prev.push(' ');
2362            prev.push_str(last.trim_start());
2363        }
2364    }
2365    merged
2366}
2367
2368/// Reflow elements for sentence-per-line mode
2369fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2370    let abbreviations = get_abbreviations(&options.abbreviations);
2371    let require_sentence_capital = options.require_sentence_capital;
2372    let mut lines = Vec::new();
2373    let mut current_line = String::new();
2374
2375    for (idx, element) in elements.iter().enumerate() {
2376        // Text and emphasis are absorbed the same way. An emphasis span is
2377        // rendered back to its source form and then treated as ordinary text,
2378        // so a sentence boundary inside it breaks the line without closing and
2379        // reopening the markers: a line break inside a span is whitespace, and
2380        // whitespace is all a reflow is allowed to change.
2381        let is_span = matches!(
2382            element,
2383            Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2384        );
2385        let piece = match element {
2386            // Text already carries its own spacing from tokenization.
2387            Element::Text(text) => Some(text.clone()),
2388            Element::Italic { content, underscore } => Some(wrap_emphasis(
2389                content,
2390                if *underscore { "_" } else { "*" },
2391                &mut current_line,
2392                source_gap_before(elements, idx),
2393            )),
2394            Element::Bold { content, underscore } => Some(wrap_emphasis(
2395                content,
2396                if *underscore { "__" } else { "**" },
2397                &mut current_line,
2398                source_gap_before(elements, idx),
2399            )),
2400            Element::Strikethrough { content, double } => Some(wrap_emphasis(
2401                content,
2402                if *double { "~~" } else { "~" },
2403                &mut current_line,
2404                source_gap_before(elements, idx),
2405            )),
2406            _ => None,
2407        };
2408
2409        if let Some(piece) = piece {
2410            // Where the piece lands in the combined line. A span begins with its
2411            // own opening marker, which the splitter must not read as the marker
2412            // closing the sentence in front of it.
2413            let appended_span_start = is_span.then_some(current_line.len());
2414            let combined = format!("{current_line}{piece}");
2415            // Use the pre-computed abbreviations set to avoid redundant computation
2416            let sentences = split_into_sentences_with_set(
2417                &combined,
2418                &abbreviations,
2419                require_sentence_capital,
2420                appended_span_start,
2421                options.defined_references.as_ref(),
2422            );
2423
2424            // A bracketed element right after the piece may hold the sentence
2425            // in front of it open: `Claim ends here. [smith](url) continues.`
2426            // is one sentence to the splitter, since the link's text starts
2427            // with a lowercase letter, and so is `Claim ends here. [Smith 2020]`.
2428            // The splitter decides by reading the sentence with the element
2429            // appended, so the check counting sentences and this reflow agree
2430            // on where the line breaks. Every other kind of element closes the
2431            // sentence in front of it.
2432            let next_bracketed = elements
2433                .get(idx + 1)
2434                .filter(|next| next.opens_with_bracket())
2435                .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
2436            let closes_before_next = |sentence: &str| -> bool {
2437                let Some((gap, next_str)) = &next_bracketed else {
2438                    return true;
2439                };
2440                let mut probe = sentence.to_string();
2441                push_source_gap(&mut probe, gap);
2442                probe.push_str(next_str);
2443                let probe_sentences = split_into_sentences_with_set(
2444                    &probe,
2445                    &abbreviations,
2446                    require_sentence_capital,
2447                    None,
2448                    options.defined_references.as_ref(),
2449                );
2450                probe_sentences.last().is_some_and(|last| last == next_str)
2451            };
2452
2453            if sentences.len() > 1 {
2454                // Accumulate rather than emit-and-overwrite: a sentence held
2455                // back for the next element must absorb what follows it, or the
2456                // text that follows would reach the output ahead of it.
2457                let mut pending = String::new();
2458                let last = sentences.len() - 1;
2459                for (i, sentence) in sentences.iter().enumerate() {
2460                    if !pending.is_empty() {
2461                        pending.push(' ');
2462                    }
2463                    pending.push_str(sentence);
2464
2465                    // The splitter already decided every boundary except the
2466                    // final one, which is just the leftover tail. Hold a tail
2467                    // that no punctuation closed, and hold any piece ending in
2468                    // an abbreviation the splitter broke after regardless.
2469                    let closed = i < last || (ends_with_sentence_punct(&pending) && closes_before_next(&pending));
2470                    if closed && !text_ends_with_abbreviation(&pending, &abbreviations) {
2471                        lines.push(std::mem::take(&mut pending));
2472                    }
2473                }
2474                current_line = pending;
2475            } else {
2476                // Single sentence - check if it's complete
2477                let trimmed = combined.trim();
2478
2479                // If the combined result is only whitespace, don't accumulate it.
2480                // This prevents leading spaces on subsequent elements when lines
2481                // are joined with spaces during reflow iteration.
2482                if trimmed.is_empty() {
2483                    continue;
2484                }
2485
2486                let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
2487
2488                if ends_with_sentence_punct
2489                    && !text_ends_with_abbreviation(trimmed, &abbreviations)
2490                    && closes_before_next(trimmed)
2491                {
2492                    // Complete single sentence - emit it (trimming only
2493                    // breakable whitespace so edge NBSPs survive)
2494                    lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
2495                    current_line.clear();
2496                } else {
2497                    // Incomplete sentence - continue accumulating
2498                    current_line = combined;
2499                }
2500            }
2501        } else {
2502            // Non-text, non-emphasis elements (Code, Links, etc.)
2503            let element_str = format!("{element}");
2504            push_source_gap(&mut current_line, source_gap_before(elements, idx));
2505            current_line.push_str(&element_str);
2506        }
2507    }
2508
2509    // Add any remaining content
2510    if !current_line.is_empty() {
2511        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
2512    }
2513    lines
2514}
2515
2516/// Restore an emphasis span to its source form, opening the gap the source had
2517/// in front of it. Unlike text elements, a span carries no surrounding
2518/// whitespace of its own.
2519fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
2520    push_source_gap(current_line, gap);
2521    format!("{marker}{content}{marker}")
2522}
2523
2524/// English break-words used for semantic line break splitting.
2525/// These are conjunctions and relative pronouns where a line break
2526/// reads naturally.
2527const BREAK_WORDS: &[&str] = &[
2528    "and",
2529    "or",
2530    "but",
2531    "nor",
2532    "yet",
2533    "so",
2534    "for",
2535    "which",
2536    "that",
2537    "because",
2538    "when",
2539    "if",
2540    "while",
2541    "where",
2542    "although",
2543    "though",
2544    "unless",
2545    "since",
2546    "after",
2547    "before",
2548    "until",
2549    "as",
2550    "once",
2551    "whether",
2552    "however",
2553    "therefore",
2554    "moreover",
2555    "furthermore",
2556    "nevertheless",
2557    "whereas",
2558];
2559
2560/// Check if a character is clause punctuation for semantic line breaks
2561fn is_clause_punctuation(c: char) -> bool {
2562    matches!(c, ',' | ';' | ':' | '\u{2014}') // comma, semicolon, colon, em dash
2563}
2564
2565/// Whether a clause-punctuation char at `chars[i]` is a legitimate break point.
2566///
2567/// A real clause boundary is followed by breakable whitespace (or ends the
2568/// text). Two reasons, and they agree: `,;:` with no following space sit
2569/// *inside* a token (`16:9`, `key:value`, a MyST role like `{cite:p}`), and a
2570/// line break renders as a space, so breaking where the source has none inserts
2571/// one. That holds for the em dash too: `cost—benefit` renders as one word,
2572/// `cost—\nbenefit` as two. A non-breaking space is not a boundary either: it
2573/// exists to forbid the break, so the scan keeps looking for an earlier one.
2574fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
2575    match chars.get(i + 1) {
2576        None => true,
2577        Some(next) => is_breakable_whitespace(*next),
2578    }
2579}
2580
2581/// Find the closing `)` that balances the `(` at the start of `slice`.
2582///
2583/// `offset` is the byte position of the `(` in the original full-line string;
2584/// it is used to translate local byte positions into global positions for
2585/// element-span lookups.  Parens inside markdown element spans are skipped so
2586/// that, e.g., the closing `)` of an inline link does not prematurely end the
2587/// scan.  The char's *start* byte (not byte-after) is used for the span check
2588/// so that closing element delimiters — which sit exactly at the span's
2589/// exclusive-end boundary — are correctly excluded.
2590///
2591/// Returns `(end_local, inner)` where `end_local` is the byte offset within
2592/// `slice` just past the closing `)`, and `inner` is the content between the
2593/// outermost `(` and `)`.
2594fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
2595    debug_assert!(slice.starts_with('('));
2596    let mut depth: i32 = 0;
2597    for (local_byte, c) in slice.char_indices() {
2598        let global_byte = offset + local_byte;
2599        // When depth > 0, skip parens that belong to a markdown element.
2600        // Use the char's start byte so that a closing element delimiter
2601        // (whose byte_after equals the span's exclusive end) is treated as
2602        // inside the element rather than outside it.
2603        if depth > 0 && is_inside_element(global_byte, element_spans) {
2604            continue;
2605        }
2606        match c {
2607            '(' => depth += 1,
2608            ')' => {
2609                depth -= 1;
2610                if depth == 0 {
2611                    let end = local_byte + 1;
2612                    let inner = &slice[1..local_byte];
2613                    return Some((end, inner));
2614                }
2615            }
2616            _ => {}
2617        }
2618    }
2619    None
2620}
2621
2622/// Split a line at a parenthetical boundary for semantic line breaks.
2623///
2624/// Two strategies are tried in order:
2625///
2626/// 1. **Leading parenthetical** — if the line begins with `(`, isolate the
2627///    entire balanced group on this line and start the rest on the next.
2628///    This handles lines produced by a prior split that placed a `(` at the
2629///    very beginning.
2630///
2631/// 2. **Mid-line parenthetical** — find the rightmost balanced `(…)` whose
2632///    content spans multiple words and whose preceding text fits within
2633///    `[min_first_len, line_length]`.  Split just before the `(` so the
2634///    parenthetical begins the following line.
2635///
2636/// Parentheses that fall inside markdown element spans (links, code, etc.)
2637/// are ignored in both strategies.
2638fn split_at_parenthetical(
2639    text: &str,
2640    line_length: usize,
2641    element_spans: &[ElementSpan],
2642    length_mode: ReflowLengthMode,
2643) -> Option<(String, String)> {
2644    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2645
2646    // Strategy 1: text starts with '(' — isolate the parenthetical as its own line.
2647    if text.starts_with('(')
2648        && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
2649        && inner.contains(' ')
2650    {
2651        // Whatever follows the closing ')' up to the next breakable whitespace
2652        // belongs to the parenthetical: a break there would render as a space the
2653        // text does not have, and it would orphan the punctuation (`).`, `),`,
2654        // `)"`) at the head of the continuation line. Whitespace inside an inline
2655        // element is that element's own content, so a boundary landing there is
2656        // no boundary at all and the scan resumes past the element.
2657        let mut first_end = end_local;
2658        loop {
2659            first_end += text[first_end..]
2660                .char_indices()
2661                .take_while(|(_, c)| !is_breakable_whitespace(*c))
2662                .last()
2663                .map_or(0, |(idx, c)| idx + c.len_utf8());
2664            match element_containing(first_end, element_spans) {
2665                Some(span) => first_end = span.end,
2666                None => break,
2667            }
2668        }
2669        let rest_start = first_end;
2670        let first = &text[..first_end];
2671        // No MIN_SPLIT_RATIO check: a parenthetical unit is always a valid
2672        // semantic line regardless of its length.
2673        if measure(first, 0, element_spans, length_mode).fits(line_length) {
2674            let rest = text[rest_start..].trim_start();
2675            if !rest.is_empty() {
2676                return Some((first.to_string(), rest.to_string()));
2677            }
2678        }
2679    }
2680
2681    // Strategy 2: find the rightmost multi-word '(' whose preceding text fits.
2682    let mut best_open_byte: Option<usize> = None;
2683    let mut pos = 0usize;
2684    while pos < text.len() {
2685        // '(' is ASCII so a single-byte comparison is safe in UTF-8.
2686        if text.as_bytes()[pos] != b'(' {
2687            let c = text[pos..].chars().next().unwrap();
2688            pos += c.len_utf8();
2689            continue;
2690        }
2691        // Skip '(' that are part of a markdown element (use start byte).
2692        if is_inside_element(pos, element_spans) {
2693            pos += 1;
2694            continue;
2695        }
2696        if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
2697            let first = text[..pos].trim_end_matches(is_breakable_whitespace);
2698            let first_len = measure(first, 0, element_spans, length_mode).effective();
2699            // The '(' must follow breakable whitespace: splitting `f(a b)` into
2700            // `f` and `(a b)` would render as `f (a b)`.
2701            if first.len() < pos
2702                && !first.is_empty()
2703                && first_len >= min_first_len
2704                && first_len <= line_length
2705                && inner.contains(' ')
2706                && best_open_byte.is_none_or(|prev| pos > prev)
2707            {
2708                best_open_byte = Some(pos);
2709            }
2710            pos += end_local;
2711        } else {
2712            pos += 1;
2713        }
2714    }
2715
2716    let open_byte = best_open_byte?;
2717    let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
2718    let rest = text[open_byte..].to_string();
2719    if first.is_empty() || rest.trim().is_empty() {
2720        return None;
2721    }
2722    Some((first, rest))
2723}
2724
2725/// A non-Text element's byte span in a flat text representation, with the
2726/// columns each of the checker's exemptions forgives it.
2727///
2728/// The offsets exist so a split position can be kept out of an element; the
2729/// savings exist so a substring can be measured the way the checker measures it.
2730/// Both are computed from the same walk over the same elements, which is what
2731/// keeps them consistent.
2732#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2733struct ElementSpan {
2734    start: usize,
2735    end: usize,
2736    full: usize,
2737    /// Columns the link/image URL exemption forgives, zero when it is off or
2738    /// does not apply to this element.
2739    link_saving: usize,
2740    /// Columns the code-span exemption forgives, on the same terms.
2741    code_saving: usize,
2742    /// Whether this element is hard atomic (cannot be broken even as fallback)
2743    is_hard: bool,
2744}
2745
2746impl ElementSpan {
2747    /// A span covering `len` bytes from `start`, for an element whose full
2748    /// width is `full` and whose exempt widths are `width`.
2749    fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
2750        Self {
2751            start,
2752            end: start + len,
2753            full,
2754            link_saving: full - width.link_exempt,
2755            code_saving: full - width.code_exempt,
2756            is_hard,
2757        }
2758    }
2759
2760    fn contains(&self, pos: usize) -> bool {
2761        pos > self.start && pos < self.end
2762    }
2763
2764    fn within(&self, start: usize, end: usize) -> bool {
2765        self.start >= start && self.end <= end
2766    }
2767
2768    fn exempt_width(&self) -> LineWidth {
2769        LineWidth {
2770            link_exempt: self.full - self.link_saving,
2771            code_exempt: self.full - self.code_saving,
2772        }
2773    }
2774}
2775
2776/// Compute element spans for a flat text representation of elements.
2777///
2778/// The offsets are byte positions, so they are always measured in
2779/// [`ReflowLengthMode::Bytes`] regardless of how lines are measured; only the
2780/// savings depend on `mode` and the active exemptions.
2781fn compute_element_spans(
2782    elements: &[Element],
2783    mode: ReflowLengthMode,
2784    exemptions: LengthExemptions,
2785) -> Vec<ElementSpan> {
2786    let mut spans = Vec::new();
2787    let mut offset = 0;
2788    for element in elements {
2789        let len = element.display_len(ReflowLengthMode::Bytes);
2790        if !matches!(element, Element::Text(_)) {
2791            let full = element.display_len(mode);
2792            let width = element.exempt_width(mode, exemptions);
2793            let is_hard = match element {
2794                Element::Bold { content, .. }
2795                | Element::Italic { content, .. }
2796                | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
2797                _ => true,
2798            };
2799            spans.push(ElementSpan::new(offset, len, full, width, is_hard));
2800        }
2801        offset += len;
2802    }
2803    spans
2804}
2805
2806/// Width of `text`, which sits at `[offset, offset + text.len())` of the line the
2807/// spans were computed for, under each exemption separately.
2808///
2809/// Only elements lying wholly inside the range are discounted. A split never
2810/// lands inside an element, so a partially covered element means the caller is
2811/// measuring something that is not a candidate line, and charging it in full is
2812/// the safe reading.
2813fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
2814    let full = display_len(text, mode);
2815    let end = offset + text.len();
2816    let mut width = LineWidth::plain(full);
2817    for span in spans.iter().filter(|span| span.within(offset, end)) {
2818        width.link_exempt -= span.link_saving;
2819        width.code_exempt -= span.code_saving;
2820    }
2821    width
2822}
2823
2824/// Width of a standalone line under each exemption.
2825///
2826/// Callers that already hold the line's element spans should use [`measure`];
2827/// this is for the sites that see only the finished line and so have to parse it.
2828fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
2829    let raw = display_len(line, options.length_mode);
2830    if !options.length_exemptions.any() {
2831        return LineWidth::plain(raw);
2832    }
2833    let elements = parse_markdown_elements_inner(
2834        line,
2835        options.attr_lists,
2836        options.myst_roles,
2837        options.defined_references.as_ref(),
2838    );
2839    let spans = compute_element_spans(&elements, options.length_mode, options.length_exemptions);
2840    measure(line, 0, &spans, options.length_mode)
2841}
2842
2843/// Width of a standalone line as the checker measures it.
2844fn line_width(line: &str, options: &ReflowOptions) -> usize {
2845    line_width_components(line, options).effective()
2846}
2847
2848/// Whether a standalone line fits the budget as the checker measures it.
2849///
2850/// A saving is never negative, so the exempt width never exceeds the raw width:
2851/// a line that already fits as written fits under any exemption too, and needs
2852/// no parse. That keeps the parse off the path most lines take.
2853fn line_fits(line: &str, options: &ReflowOptions) -> bool {
2854    display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
2855}
2856
2857/// The non-Text element span that strictly contains `pos`, if any.
2858fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
2859    spans.iter().copied().find(|span| span.contains(pos))
2860}
2861
2862/// Check if a byte position falls inside any non-Text element span
2863fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
2864    element_containing(pos, spans).is_some()
2865}
2866
2867/// Minimum fraction of line_length that the first part of a split must occupy.
2868/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
2869const MIN_SPLIT_RATIO: f64 = 0.3;
2870
2871/// Split a line at the latest clause punctuation that keeps the first part
2872/// within `line_length`. Returns None if no valid split point exists or if
2873/// the split would create an unreasonably short first line.
2874fn split_at_clause_punctuation(
2875    text: &str,
2876    line_length: usize,
2877    element_spans: &[ElementSpan],
2878    length_mode: ReflowLengthMode,
2879) -> Option<(String, String)> {
2880    let chars: Vec<char> = text.chars().collect();
2881    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
2882
2883    // Find the char index where accumulated display width exceeds line_length.
2884    // An element the checker discounts is charged its reduced width and stepped
2885    // over whole, so the search window reaches as far as the exempt measure of a
2886    // prefix allows; scanning char by char through a discounted URL would stop
2887    // short of break points that are legal under it.
2888    let mut width_acc = LineWidth::default();
2889    let mut search_end_char = 0;
2890    let mut byte = 0usize;
2891    let mut idx = 0usize;
2892    while idx < chars.len() {
2893        let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
2894            Some(span) => {
2895                let source = &text[span.start..span.end];
2896                (
2897                    source.chars().count(),
2898                    source.len(),
2899                    measure(source, span.start, element_spans, length_mode),
2900                )
2901            }
2902            None => {
2903                let c = chars[idx];
2904                (
2905                    1,
2906                    c.len_utf8(),
2907                    LineWidth::plain(display_len(&c.to_string(), length_mode)),
2908                )
2909            }
2910        };
2911        if !(width_acc + width).fits(line_length) {
2912            break;
2913        }
2914        width_acc += width;
2915        byte += advance_bytes;
2916        idx += advance_chars;
2917        search_end_char = idx;
2918    }
2919
2920    // Scan backwards tracking parenthesis depth to skip clause punctuation
2921    // inside plain-text parenthetical groups.  Scanning right-to-left means
2922    // ')' opens a depth level and '(' closes it.  Parens that belong to a
2923    // markdown element are excluded using the char's start byte (not byte-after)
2924    // so that closing element delimiters at the span boundary are correctly
2925    // treated as part of the element.
2926    let mut paren_depth: i32 = 0;
2927    let mut best_pos = None;
2928    for i in (0..search_end_char).rev() {
2929        // Start byte of char i (for paren element check)
2930        let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
2931        // Byte just after char i (for clause punctuation element check — existing convention)
2932        let byte_after: usize = byte_start + chars[i].len_utf8();
2933
2934        if !is_inside_element(byte_start, element_spans) {
2935            match chars[i] {
2936                ')' => paren_depth += 1,
2937                '(' => paren_depth = paren_depth.saturating_sub(1),
2938                _ => {}
2939            }
2940        }
2941
2942        if paren_depth == 0
2943            && is_clause_punctuation(chars[i])
2944            && clause_break_allowed_after(&chars, i)
2945            && !is_inside_element(byte_after, element_spans)
2946        {
2947            best_pos = Some(i);
2948            break;
2949        }
2950    }
2951
2952    let pos = best_pos?;
2953
2954    // Reject splits that create very short first lines
2955    let first: String = chars[..=pos].iter().collect();
2956    if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
2957        return None;
2958    }
2959
2960    // Split after the punctuation character
2961    let rest: String = chars[pos + 1..].iter().collect();
2962    let rest = rest.trim_start().to_string();
2963
2964    if rest.is_empty() {
2965        return None;
2966    }
2967
2968    Some((first, rest))
2969}
2970
2971/// Compute plain-text paren-depth at each byte offset in `text`.
2972///
2973/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
2974/// nesting depth at byte `i` — counting only `(` and `)` that fall
2975/// outside markdown element spans.  This lets callers quickly check
2976/// whether a byte position lies inside a plain-text parenthetical group.
2977fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
2978    let mut map = vec![0i32; text.len()];
2979    let mut depth = 0i32;
2980    for (byte, c) in text.char_indices() {
2981        if !is_inside_element(byte, element_spans) {
2982            match c {
2983                '(' => depth += 1,
2984                ')' => depth = depth.saturating_sub(1),
2985                _ => {}
2986            }
2987        }
2988        // Fill the depth value for every byte of this (possibly multi-byte) char.
2989        let end = (byte + c.len_utf8()).min(map.len());
2990        for slot in &mut map[byte..end] {
2991            *slot = depth;
2992        }
2993    }
2994    map
2995}
2996
2997/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
2998/// group — i.e. it starts with `(`, ends with `)` (possibly followed by the
2999/// punctuation `split_at_parenthetical` attaches to it), has balanced parens
3000/// throughout, and the inner content contains at least one space (matching the
3001/// ≥2-word threshold used by `split_at_parenthetical` when deciding to split).
3002///
3003/// Used to prevent the short-line merge step from collapsing intentional
3004/// parenthetical splits back into the previous line.
3005fn is_standalone_parenthetical(line: &str) -> bool {
3006    let trimmed = line.trim();
3007    if !trimmed.starts_with('(') {
3008        return false;
3009    }
3010    // Strip the attached tail to find the real end: everything after the last
3011    // ')' belongs to the group only when no whitespace separates it.
3012    let Some(close) = trimmed.rfind(')') else {
3013        return false;
3014    };
3015    if trimmed[close + 1..].contains(char::is_whitespace) {
3016        return false;
3017    }
3018    let core = &trimmed[..=close];
3019    // Inner content must span multiple words (same threshold as split_at_parenthetical).
3020    let inner = &core[1..core.len() - 1];
3021    if !inner.contains(' ') {
3022        return false;
3023    }
3024    // Verify the parens are balanced (depth returns to 0 at the last ')').
3025    let mut depth = 0i32;
3026    for c in core.chars() {
3027        match c {
3028            '(' => depth += 1,
3029            ')' => depth -= 1,
3030            _ => {}
3031        }
3032        if depth < 0 {
3033            return false;
3034        }
3035    }
3036    depth == 0
3037}
3038
3039/// Split a line before the latest break-word that keeps the first part
3040/// within `line_length`. Returns None if no valid split point exists or if
3041/// the split would create an unreasonably short first line.
3042fn split_at_break_word(
3043    text: &str,
3044    line_length: usize,
3045    element_spans: &[ElementSpan],
3046    length_mode: ReflowLengthMode,
3047) -> Option<(String, String)> {
3048    let lower = text.to_lowercase();
3049    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3050    let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
3051
3052    // Build a paren-depth map so we can skip break-words inside plain-text
3053    // parenthetical groups (matching the protection added to split_at_clause_punctuation).
3054    let depth_map = paren_depth_map(text, element_spans);
3055
3056    for &word in BREAK_WORDS {
3057        let mut search_start = 0;
3058        while let Some(pos) = lower[search_start..].find(word) {
3059            let abs_pos = search_start + pos;
3060
3061            // Verify it's a word boundary: preceded by space, followed by space
3062            let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
3063            let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
3064
3065            if preceded_by_space && followed_by_space {
3066                // The break goes BEFORE the word, so first part ends at abs_pos - 1
3067                let first_part = text[..abs_pos].trim_end();
3068                let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
3069
3070                // Skip break-words inside plain-text parenthetical groups.
3071                let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
3072
3073                if first_part_len >= min_first_len
3074                    && first_part_len <= line_length
3075                    && !is_inside_element(abs_pos, element_spans)
3076                    && !inside_paren
3077                {
3078                    // Prefer the latest valid split point
3079                    if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
3080                        best_split = Some((abs_pos, word.len()));
3081                    }
3082                }
3083            }
3084
3085            search_start = abs_pos + word.len();
3086        }
3087    }
3088
3089    let (byte_start, _word_len) = best_split?;
3090
3091    let first = text[..byte_start].trim_end().to_string();
3092    let rest = text[byte_start..].to_string();
3093
3094    if first.is_empty() || rest.trim().is_empty() {
3095        return None;
3096    }
3097
3098    Some((first, rest))
3099}
3100
3101/// Whether a proposed split takes the place of whitespace that `text` already has.
3102///
3103/// `first` is a prefix of `text` with its trailing whitespace removed and `rest`
3104/// a suffix with its leading whitespace removed, so the bytes between them are
3105/// exactly what the split consumed. That gap must be non-empty and hold nothing
3106/// but breakable whitespace the paragraph owns: the newline replacing it renders
3107/// as a single space, so an empty gap inserts a word boundary the author did not
3108/// write, and a gap holding anything else drops content. Whitespace inside an
3109/// inline element belongs to that element, where it is literal (a code span) or
3110/// structural (a link destination), never a place a line may break.
3111fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
3112    if !text.starts_with(first) || !text.ends_with(rest) {
3113        return false;
3114    }
3115    let gap_end = text.len() - rest.len();
3116    gap_end > first.len()
3117        && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
3118        && !element_spans
3119            .iter()
3120            .any(|span| first.len() < span.end && span.start < gap_end)
3121}
3122
3123/// Cascade-split a line that exceeds line_length.
3124/// Tries parenthetical boundaries, then clause punctuation, then break-words,
3125/// then word wrap.
3126///
3127/// This is iterative rather than recursive so a single very long line (tens of
3128/// thousands of words) cannot overflow the stack. Each accepted split shrinks
3129/// the remaining text by a non-empty prefix, so the loop always makes progress.
3130/// The whole line is parsed into markdown elements once up front; every
3131/// remaining suffix reuses those element spans (re-based to the suffix offset)
3132/// instead of re-parsing, which keeps repeated element parsing out of the loop.
3133fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
3134    let line_length = options.line_length;
3135    let length_mode = options.length_mode;
3136    let attr_lists = options.attr_lists;
3137    let myst_roles = options.myst_roles;
3138    let defined_references = options.defined_references.as_ref();
3139    if line_length == 0 || display_len(text, length_mode) <= line_length {
3140        return vec![text.to_string()];
3141    }
3142
3143    let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
3144    let element_spans = compute_element_spans(&elements, length_mode, options.length_exemptions);
3145
3146    // The raw width is over budget, but an exemption may still bring the line
3147    // under it, in which case the checker accepts it as written.
3148    if measure(text, 0, &element_spans, length_mode).fits(line_length) {
3149        return vec![text.to_string()];
3150    }
3151
3152    // Element spans of the remaining suffix `text[start..]`, re-based so their
3153    // offsets are relative to the suffix. Split points never fall inside an
3154    // element, so every span lies wholly before or wholly at/after `start`.
3155    let rebased_spans = |start: usize| -> Vec<ElementSpan> {
3156        if start == 0 {
3157            return element_spans.clone();
3158        }
3159        element_spans
3160            .iter()
3161            .filter(|span| span.end > start)
3162            .map(|span| ElementSpan {
3163                start: span.start.saturating_sub(start),
3164                end: span.end.saturating_sub(start),
3165                ..*span
3166            })
3167            .collect()
3168    };
3169
3170    let mut result = Vec::new();
3171    let mut start = 0usize;
3172
3173    loop {
3174        let remaining = &text[start..];
3175        let spans = rebased_spans(start);
3176        if measure(remaining, 0, &spans, length_mode).fits(line_length) {
3177            result.push(remaining.to_string());
3178            return result;
3179        }
3180
3181        // `rest` is always a suffix of `remaining` (the splitters only trim its
3182        // leading whitespace), so `remaining.len() - rest.len()` is the number of
3183        // bytes consumed, and the new absolute offset is `start + consumed`.
3184        //
3185        // Every candidate must stand in for whitespace the text already has: a
3186        // line break renders as a space, so one placed between two characters
3187        // that were adjacent changes the rendered paragraph. A strategy that
3188        // proposes such a split is skipped and the next one gets a turn.
3189        let at_whitespace = |candidate: Option<(String, String)>| {
3190            candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
3191        };
3192        let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
3193            .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
3194            .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
3195
3196        if let Some((first, rest)) = split {
3197            let consumed = remaining.len().saturating_sub(rest.len());
3198            // Defensive: a zero-length advance would loop forever. Splitters only
3199            // return a non-empty `first`, so this never triggers, but guard anyway.
3200            if consumed == 0 {
3201                break;
3202            }
3203            result.push(first);
3204            start += consumed;
3205            continue;
3206        }
3207
3208        // No semantic split point: word-wrap the remaining suffix and finish.
3209        break;
3210    }
3211
3212    // Fallback: word wrap the still-oversized suffix using reflow_elements.
3213    let mut fallback_options = options.clone();
3214    fallback_options.break_on_sentences = false;
3215    fallback_options.preserve_breaks = false;
3216    fallback_options.sentence_per_line = false;
3217    fallback_options.semantic_line_breaks = false;
3218    fallback_options.require_sentence_capital = true;
3219    fallback_options.max_list_continuation_indent = None;
3220    fallback_options.defined_references = None;
3221    let remaining = &text[start..];
3222    let tail_elements = if start == 0 {
3223        elements
3224    } else {
3225        parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
3226    };
3227    result.extend(reflow_elements(&tail_elements, &fallback_options));
3228    result
3229}
3230
3231/// Reflow elements using semantic line breaks strategy:
3232/// 1. Split at sentence boundaries (always)
3233/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
3234fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3235    // Step 1: Split into sentences using existing sentence-per-line logic
3236    let sentence_lines = reflow_elements_sentence_per_line(elements, options);
3237
3238    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
3239    // When line_length is 0 (unlimited), skip cascading — sentence splits only
3240    if options.line_length == 0 {
3241        return sentence_lines;
3242    }
3243
3244    let mut result = Vec::new();
3245    for line in sentence_lines {
3246        if line_fits(&line, options) {
3247            result.push(line);
3248        } else {
3249            result.extend(cascade_split_line(&line, options));
3250        }
3251    }
3252
3253    // Step 3: Merge very short trailing lines back into the previous line.
3254    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
3255    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
3256    let mut merged: Vec<String> = Vec::with_capacity(result.len());
3257    for line in result {
3258        if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
3259            // Don't merge a line that is itself a standalone parenthetical group —
3260            // it was placed on its own line intentionally by split_at_parenthetical.
3261            if is_standalone_parenthetical(&line) {
3262                merged.push(line);
3263                continue;
3264            }
3265
3266            // Don't merge across sentence boundaries — sentence splits are intentional
3267            let prev_ends_at_sentence = {
3268                let trimmed = merged.last().unwrap().trim_end();
3269                trimmed
3270                    .chars()
3271                    .rev()
3272                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
3273                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
3274            };
3275
3276            if !prev_ends_at_sentence {
3277                let prev = merged.last_mut().unwrap();
3278                let combined = format!("{prev} {line}");
3279                // Only merge if the combined line fits within the limit
3280                if line_fits(&combined, options) {
3281                    *prev = combined;
3282                    continue;
3283                }
3284            }
3285        }
3286        merged.push(line);
3287    }
3288    merged
3289}
3290
3291/// Find the last space in `line` that is safe to split at.
3292/// Safe spaces are those NOT inside rendered non-Text elements and whose
3293/// suffix would not open a block construct when placed at line start.
3294/// `element_spans` locates the non-Text elements in the line. Spans use
3295/// exclusive bounds (pos > start && pos < end) because element delimiters
3296/// (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never spaces, so only
3297/// interior positions need protection. The scan keeps looking left past
3298/// construct-leading suffixes (e.g. a trailing `- `), so a usable earlier break
3299/// point is found instead of forcing an overlong line.
3300fn rfind_safe_space(
3301    line: &str,
3302    element_spans: &[ElementSpan],
3303    options: &ReflowOptions,
3304    relax_soft_spans: bool,
3305) -> Option<usize> {
3306    line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
3307        line.as_bytes()[pos] == b' '
3308            && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
3309            && !starts_block_construct(&line[pos + 1..])
3310    })
3311}
3312
3313fn is_inside_element_filtered(
3314    pos: usize,
3315    spans: &[ElementSpan],
3316    options: &ReflowOptions,
3317    relax_soft_spans: bool,
3318) -> bool {
3319    spans.iter().any(|span| {
3320        span.contains(pos)
3321            && (!relax_soft_spans
3322                || span.is_hard
3323                || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
3324    })
3325}
3326
3327/// A token that must not start a wrapped line, together with the width it
3328/// contributes and the separator that precedes it. The width travels with the
3329/// text because only the caller knows which construct produced it, and so which
3330/// exemption it earns.
3331#[derive(Clone, Copy)]
3332struct Attached<'a> {
3333    text: &'a str,
3334    width: LineWidth,
3335    separator: &'a str,
3336}
3337
3338/// Break `current_line` one word earlier so `attach` never starts a wrapped
3339/// line: everything before the line's last safe space is emitted as a
3340/// finished line, and the carried word plus the separator plus the attached
3341/// text becomes the new current line. The returned byte length of the carried
3342/// word lets callers re-record a span for the attached text. Returns `None`
3343/// (line untouched) when the line has no safe break point.
3344///
3345/// The new width is the carried text measured through the spans it came with,
3346/// plus the attached width, so an exemption the carried text or the attached
3347/// token earns is preserved across the break instead of being re-derived from a
3348/// bare string.
3349///
3350/// The carried text keeps the element spans that fell inside it, rebased to the
3351/// new line. Dropping them would leave a later break blind to an element the
3352/// carried text still holds, and so free to split a link or a code span down
3353/// the middle.
3354fn break_before_attached(
3355    lines: &mut Vec<String>,
3356    current_line: &mut String,
3357    current_width: &mut LineWidth,
3358    element_spans: &mut Vec<ElementSpan>,
3359    attach: Attached<'_>,
3360    options: &ReflowOptions,
3361) -> Option<usize> {
3362    let length_mode = options.length_mode;
3363    let last_space = rfind_safe_space(current_line, element_spans, options, false)
3364        .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
3365    let before = current_line[..last_space]
3366        .trim_end_matches(is_breakable_whitespace)
3367        .to_string();
3368    let after = current_line[last_space + 1..].to_string();
3369    let after_width = measure(&after, last_space + 1, element_spans, length_mode);
3370    lines.push(before);
3371    let carried = after.len();
3372    let Attached { text, width, separator } = attach;
3373    *current_line = format!("{after}{separator}{text}");
3374    *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
3375    rebase_spans_after_break(element_spans, last_space + 1);
3376    Some(carried)
3377}
3378
3379/// Keep the spans that reach into the text starting at `carried_start` and move
3380/// them into that text's coordinates, discarding the ones that belong to the
3381/// line just emitted.
3382///
3383/// A span that starts before `carried_start` is clamped to 0 rather than
3384/// dropped. `rfind_safe_space` never breaks inside a span, so this cannot
3385/// normally happen; clamping keeps the whole prefix protected if it ever does,
3386/// where dropping the span would license a break inside an element.
3387fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
3388    element_spans.retain(|span| span.end > carried_start);
3389    for span in element_spans.iter_mut() {
3390        span.start = span.start.saturating_sub(carried_start);
3391        span.end -= carried_start;
3392    }
3393}
3394
3395/// Reflow elements into lines that fit within the line length
3396fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3397    let mut lines = Vec::new();
3398    let mut current_line = String::new();
3399    // The line's width under each exemption the checker applies. With no
3400    // exemption active both components are the plain display width.
3401    let mut current_width = LineWidth::default();
3402    // Track byte spans of non-Text elements in current_line for safe splitting
3403    let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
3404    let length_mode = options.length_mode;
3405    let exemptions = options.length_exemptions;
3406
3407    for (idx, element) in elements.iter().enumerate() {
3408        let element_len = element.display_len(length_mode);
3409        let element_width = element.exempt_width(length_mode, exemptions);
3410        let is_hard = match element {
3411            Element::Bold { content, .. }
3412            | Element::Italic { content, .. }
3413            | Element::Strikethrough { content, .. } => content.contains(['[', '`', '<', '$', '{']),
3414            _ => true,
3415        };
3416
3417        // Determine adjacency from the original elements, not from current_line.
3418        // Elements are adjacent when there's no breakable whitespace between them
3419        // in the source (a non-breaking space stays inside the neighboring token,
3420        // so the pair must also stay attached):
3421        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
3422        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
3423        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
3424        // - Code("`x`") → Text("\u{00A0}:") = adjacent (only a non-breaking space between)
3425        let is_adjacent_to_prev = if idx > 0 {
3426            match (&elements[idx - 1], element) {
3427                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
3428                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
3429                _ => true,
3430            }
3431        } else {
3432            false
3433        };
3434
3435        // For text elements that might need breaking
3436        if let Element::Text(text) = element {
3437            // Check if original text had leading breakable whitespace
3438            let has_leading_space = text.starts_with(is_breakable_whitespace);
3439            // If this is a text element, always process it word by word
3440            let words: Vec<&str> = split_breakable_words(text).collect();
3441
3442            for (i, word) in words.iter().enumerate() {
3443                // A bare word carries no construct the checker exempts.
3444                let word_width = LineWidth::plain(display_len(word, length_mode));
3445                // A token that is only punctuation (optionally led by a
3446                // non-breaking space, e.g. French "\u{00A0}:") must never be
3447                // hoisted to the start of a line. Tokens are never empty
3448                // (`split_breakable_words` filters), so `all` cannot be
3449                // vacuously true.
3450                let is_trailing_punct = word.chars().all(|c| {
3451                    matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
3452                });
3453
3454                // First word of text adjacent to preceding non-text element
3455                // must stay attached (e.g., shortcode followed by punctuation or text)
3456                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
3457
3458                if is_first_adjacent {
3459                    // Attach directly without space, preventing line break
3460                    if !(current_width + word_width).fits(options.line_length)
3461                        && !current_width.is_empty()
3462                        && break_before_attached(
3463                            &mut lines,
3464                            &mut current_line,
3465                            &mut current_width,
3466                            &mut current_line_element_spans,
3467                            Attached {
3468                                text: word,
3469                                width: word_width,
3470                                separator: "",
3471                            },
3472                            options,
3473                        )
3474                        .is_some()
3475                    {
3476                        // Would exceed — broke before the adjacent group at the
3477                        // last safe space (element-aware, so links/code stay
3478                        // intact); with no safe break point the group is
3479                        // attached and the long line accepted.
3480                    } else {
3481                        current_line.push_str(word);
3482                        current_width += word_width;
3483                    }
3484                } else if !current_width.is_empty()
3485                    && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3486                {
3487                    if is_trailing_punct {
3488                        // The overflowing token is bare punctuation, which must
3489                        // not start a line. Break one word earlier so the mark
3490                        // travels with the word it follows ("… mot :"), keeping
3491                        // the source space (French double punctuation requires
3492                        // it); with no safe earlier break point, accept the
3493                        // overlong line rather than rewrite content.
3494                        if break_before_attached(
3495                            &mut lines,
3496                            &mut current_line,
3497                            &mut current_width,
3498                            &mut current_line_element_spans,
3499                            Attached {
3500                                text: word,
3501                                width: word_width,
3502                                separator: " ",
3503                            },
3504                            options,
3505                        )
3506                        .is_none()
3507                        {
3508                            current_line.push(' ');
3509                            current_line.push_str(word);
3510                            current_width += LineWidth::plain(1) + word_width;
3511                        }
3512                    } else if !starts_block_construct(word) {
3513                        // Start a new line
3514                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3515                        current_line = word.to_string();
3516                        current_width = word_width;
3517                        current_line_element_spans.clear();
3518                    } else if break_before_attached(
3519                        &mut lines,
3520                        &mut current_line,
3521                        &mut current_width,
3522                        &mut current_line_element_spans,
3523                        Attached {
3524                            text: word,
3525                            width: word_width,
3526                            separator: " ",
3527                        },
3528                        options,
3529                    )
3530                    .is_some()
3531                    {
3532                        // The overflowing word would open a block construct at line
3533                        // start. Broke one word earlier instead so the marker stays
3534                        // mid-line: "... and then" + "- clause" becomes "... and" +
3535                        // "then - clause".
3536                    } else {
3537                        // No safe earlier break point — keep the marker attached and
3538                        // accept the long line rather than corrupt the structure.
3539                        if i > 0 || has_leading_space {
3540                            current_line.push(' ');
3541                            current_width += LineWidth::plain(1);
3542                        }
3543                        current_line.push_str(word);
3544                        current_width += word_width;
3545                    }
3546                } else {
3547                    // Add a space wherever the source had breakable whitespace at
3548                    // this position. For the first word of a text run (i == 0)
3549                    // that means the run had a leading space — and reaching this
3550                    // branch already implies the word is not adjacent to the
3551                    // previous element, so the space is real. Later words
3552                    // (i > 0) always had whitespace before them: that is what
3553                    // separated them during tokenization. This holds for bare
3554                    // punctuation too ("ligne : la" keeps its French
3555                    // orthographic space): reflow moves line breaks, it does not
3556                    // rewrite characters. The no-space (adjacent) case is
3557                    // handled above by `is_first_adjacent`.
3558                    let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
3559                    if add_space {
3560                        current_line.push(' ');
3561                        current_width += LineWidth::plain(1);
3562                    }
3563                    current_line.push_str(word);
3564                    current_width += word_width;
3565                }
3566            }
3567        } else {
3568            let span_info = match element {
3569                Element::Italic { content, underscore } => {
3570                    Some((content.as_str(), if *underscore { "_" } else { "*" }, false))
3571                }
3572                Element::Bold { content, underscore } => {
3573                    Some((content.as_str(), if *underscore { "__" } else { "**" }, false))
3574                }
3575                Element::Strikethrough { content, double } => {
3576                    Some((content.as_str(), if *double { "~~" } else { "~" }, false))
3577                }
3578                Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)),
3579                _ => None,
3580            };
3581
3582            // A span that alone exceeds the line budget is broken even when
3583            // spans are atomic, since keeping it whole would leave a line that can
3584            // never fit. `breakable_units` decides where that is safe.
3585            let breakable: Option<Vec<&str>> = match span_info {
3586                Some((content, _, is_code)) => {
3587                    if is_code {
3588                        (!options.atomic_spans && code_span_wraps_losslessly(content))
3589                            .then(|| split_breakable_words(content).collect())
3590                    } else {
3591                        (!options.atomic_spans || element_len > options.line_length)
3592                            .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
3593                            .flatten()
3594                    }
3595                }
3596                None => None,
3597            };
3598
3599            if let Some(words) = breakable {
3600                let (_, marker, is_code) = span_info.expect("breakable implies a span");
3601                let n = words.len();
3602                if n == 0 {
3603                    // Empty span — treat as atomic
3604                    let full = format!("{marker}{marker}");
3605                    let full_width = LineWidth::plain(display_len(&full, length_mode));
3606                    if !is_adjacent_to_prev && !current_width.is_empty() {
3607                        current_line.push(' ');
3608                        current_width += LineWidth::plain(1);
3609                    }
3610                    current_line.push_str(&full);
3611                    current_width += full_width;
3612                } else {
3613                    for (i, word) in words.iter().enumerate() {
3614                        let is_first = i == 0;
3615                        let is_last = i == n - 1;
3616
3617                        let space_start = if is_first && is_code && word.starts_with('`') {
3618                            " "
3619                        } else {
3620                            ""
3621                        };
3622                        let space_end = if is_last && is_code && word.ends_with('`') {
3623                            " "
3624                        } else {
3625                            ""
3626                        };
3627
3628                        let word_str: String = match (is_first, is_last) {
3629                            (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"),
3630                            (true, false) => format!("{marker}{space_start}{word}"),
3631                            (false, true) => format!("{word}{space_end}{marker}"),
3632                            (false, false) => word.to_string(),
3633                        };
3634                        let word_elements = parse_elements(&word_str, options);
3635                        let word_spans = compute_element_spans(&word_elements, length_mode, exemptions);
3636                        let word_width = measure(&word_str, 0, &word_spans, length_mode);
3637
3638                        let needs_space = if is_first {
3639                            !is_adjacent_to_prev && !current_width.is_empty()
3640                        } else {
3641                            !current_width.is_empty()
3642                        };
3643
3644                        if needs_space
3645                            && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
3646                            && !starts_block_construct(&word_str)
3647                        {
3648                            lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3649                            current_line = word_str;
3650                            current_width = word_width;
3651                            current_line_element_spans.clear();
3652                            for span in word_spans {
3653                                current_line_element_spans.push(span);
3654                            }
3655                        } else {
3656                            let mut start_pos = current_line.len();
3657                            if needs_space {
3658                                current_line.push(' ');
3659                                current_width += LineWidth::plain(1);
3660                                start_pos += 1;
3661                            }
3662                            current_line.push_str(&word_str);
3663                            current_width += word_width;
3664                            for mut span in word_spans {
3665                                span.start += start_pos;
3666                                span.end += start_pos;
3667                                current_line_element_spans.push(span);
3668                            }
3669                        }
3670                    }
3671                }
3672            } else {
3673                // For non-text elements (code, links, references), treat as atomic units
3674                // These should never be broken across lines
3675                let element_str = format!("{element}");
3676
3677                if is_adjacent_to_prev {
3678                    // Adjacent to preceding text — attach directly without space
3679                    if !(current_width + element_width).fits(options.line_length)
3680                        && let Some(carried) = break_before_attached(
3681                            &mut lines,
3682                            &mut current_line,
3683                            &mut current_width,
3684                            &mut current_line_element_spans,
3685                            Attached {
3686                                text: &element_str,
3687                                width: element_width,
3688                                separator: "",
3689                            },
3690                            options,
3691                        )
3692                    {
3693                        // Would exceed limit — broke before the adjacent word group
3694                        // at the last safe space (element-aware, so links/code stay
3695                        // intact). Record the element span in the new current_line.
3696                        current_line_element_spans.push(ElementSpan::new(
3697                            carried,
3698                            element_str.len(),
3699                            element_len,
3700                            element_width,
3701                            is_hard,
3702                        ));
3703                    } else {
3704                        let start = current_line.len();
3705                        current_line.push_str(&element_str);
3706                        current_width += element_width;
3707                        current_line_element_spans.push(ElementSpan::new(
3708                            start,
3709                            element_str.len(),
3710                            element_len,
3711                            element_width,
3712                            is_hard,
3713                        ));
3714                    }
3715                } else if !current_width.is_empty()
3716                    && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
3717                {
3718                    if !starts_block_construct(&element_str) {
3719                        // Not adjacent, would exceed — start new line
3720                        lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
3721                        current_line.clone_from(&element_str);
3722                        current_width = element_width;
3723                        current_line_element_spans.clear();
3724                        current_line_element_spans.push(ElementSpan::new(
3725                            0,
3726                            element_str.len(),
3727                            element_len,
3728                            element_width,
3729                            is_hard,
3730                        ));
3731                    } else if let Some(carried) = break_before_attached(
3732                        &mut lines,
3733                        &mut current_line,
3734                        &mut current_width,
3735                        &mut current_line_element_spans,
3736                        Attached {
3737                            text: &element_str,
3738                            width: element_width,
3739                            separator: " ",
3740                        },
3741                        options,
3742                    ) {
3743                        // The overflowing element would open a block construct at
3744                        // line start (e.g. an HtmlTag like `<div>`). Broke one word
3745                        // earlier instead so the element stays mid-line.
3746                        let start = carried + 1;
3747                        current_line_element_spans.push(ElementSpan::new(
3748                            start,
3749                            element_str.len(),
3750                            element_len,
3751                            element_width,
3752                            is_hard,
3753                        ));
3754                    } else {
3755                        // No safe earlier break point — keep the element attached
3756                        // and accept the long line rather than corrupt the structure.
3757                        let ends_with_opener =
3758                            current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3759                        if !ends_with_opener {
3760                            current_line.push(' ');
3761                            current_width += LineWidth::plain(1);
3762                        }
3763                        let start = current_line.len();
3764                        current_line.push_str(&element_str);
3765                        current_width += element_width;
3766                        current_line_element_spans.push(ElementSpan::new(
3767                            start,
3768                            element_str.len(),
3769                            element_len,
3770                            element_width,
3771                            is_hard,
3772                        ));
3773                    }
3774                } else {
3775                    // Not adjacent, fits — add with space
3776                    let ends_with_opener =
3777                        current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
3778                    if !current_width.is_empty() && !ends_with_opener {
3779                        current_line.push(' ');
3780                        current_width += LineWidth::plain(1);
3781                    }
3782                    let start = current_line.len();
3783                    current_line.push_str(&element_str);
3784                    current_width += element_width;
3785                    current_line_element_spans.push(ElementSpan::new(
3786                        start,
3787                        element_str.len(),
3788                        element_len,
3789                        element_width,
3790                        is_hard,
3791                    ));
3792                }
3793            }
3794        }
3795    }
3796
3797    // Don't forget the last line
3798    if !current_line.is_empty() {
3799        lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
3800    }
3801
3802    lines
3803}
3804
3805/// Reflow markdown content preserving structure
3806pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
3807    let lines: Vec<&str> = content.lines().collect();
3808    let mut result = Vec::new();
3809    let mut i = 0;
3810
3811    while i < lines.len() {
3812        let line = lines[i];
3813        let trimmed = line.trim();
3814
3815        // Preserve empty lines
3816        if trimmed.is_empty() {
3817            result.push(String::new());
3818            i += 1;
3819            continue;
3820        }
3821
3822        // Preserve headings as-is
3823        if trimmed.starts_with('#') {
3824            result.push(line.to_string());
3825            i += 1;
3826            continue;
3827        }
3828
3829        // Preserve Quarto/Pandoc div markers (:::) as-is
3830        if trimmed.starts_with(":::") {
3831            result.push(line.to_string());
3832            i += 1;
3833            continue;
3834        }
3835
3836        // Preserve fenced code blocks
3837        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
3838            result.push(line.to_string());
3839            i += 1;
3840            // Copy lines until closing fence
3841            while i < lines.len() {
3842                result.push(lines[i].to_string());
3843                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
3844                    i += 1;
3845                    break;
3846                }
3847                i += 1;
3848            }
3849            continue;
3850        }
3851
3852        // Preserve indented code blocks (4+ columns accounting for tab expansion)
3853        if calculate_indentation_width_default(line) >= 4 {
3854            // Collect all consecutive indented lines
3855            result.push(line.to_string());
3856            i += 1;
3857            while i < lines.len() {
3858                let next_line = lines[i];
3859                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
3860                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
3861                    result.push(next_line.to_string());
3862                    i += 1;
3863                } else {
3864                    break;
3865                }
3866            }
3867            continue;
3868        }
3869
3870        // Preserve block quotes (but reflow their content)
3871        if trimmed.starts_with('>') {
3872            // find() returns byte position which is correct for str slicing
3873            // The unwrap is safe because we already verified trimmed starts with '>'
3874            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
3875            let quote_prefix = line[0..=gt_pos].to_string();
3876            let quote_content = &line[quote_prefix.len()..].trim_start();
3877
3878            let reflowed = reflow_line(quote_content, options);
3879            for reflowed_line in &reflowed {
3880                result.push(format!("{quote_prefix} {reflowed_line}"));
3881            }
3882            i += 1;
3883            continue;
3884        }
3885
3886        // Preserve horizontal rules first (before checking for lists)
3887        if is_horizontal_rule(trimmed) {
3888            result.push(line.to_string());
3889            i += 1;
3890            continue;
3891        }
3892
3893        // Preserve lists (but not horizontal rules)
3894        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
3895            // Find the list marker and preserve indentation
3896            let indent = line.len() - line.trim_start().len();
3897            let indent_str = " ".repeat(indent);
3898
3899            // For numbered lists, find the period and the space after it
3900            // For bullet lists, find the marker and the space after it
3901            let mut marker_end = indent;
3902            let mut content_start = indent;
3903
3904            if trimmed.chars().next().is_some_and(char::is_numeric) {
3905                // Numbered list: find the period
3906                if let Some(period_pos) = line[indent..].find('.') {
3907                    marker_end = indent + period_pos + 1; // Include the period
3908                    content_start = marker_end;
3909                    // Skip any spaces after the period to find content start
3910                    // Use byte-based check since content_start is a byte index
3911                    // This is safe because space is ASCII (single byte)
3912                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3913                        content_start += 1;
3914                    }
3915                }
3916            } else {
3917                // Bullet list: marker is single character
3918                marker_end = indent + 1; // Just the marker character
3919                content_start = marker_end;
3920                // Skip any spaces after the marker
3921                // Use byte-based check since content_start is a byte index
3922                // This is safe because space is ASCII (single byte)
3923                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
3924                    content_start += 1;
3925                }
3926            }
3927
3928            // Minimum indent for continuation lines (based on list marker, before checkbox)
3929            let min_continuation_indent = content_start;
3930
3931            // Detect checkbox/task list markers: [ ], [x], [X]
3932            // GFM task lists work with both unordered and ordered lists
3933            let rest = &line[content_start..];
3934            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
3935                marker_end = content_start + 3; // Include the checkbox `[ ]`
3936                content_start += 4; // Skip past `[ ] `
3937            }
3938
3939            let marker = &line[indent..marker_end];
3940
3941            // Collect all content for this list item (including continuation lines)
3942            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
3943            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
3944            i += 1;
3945
3946            // Collect continuation lines (indented lines that are part of this list item)
3947            // Use the base marker indent (not checkbox-extended) for collection,
3948            // since users may indent continuations to the bullet level, not the checkbox level
3949            while i < lines.len() {
3950                let next_line = lines[i];
3951                let next_trimmed = next_line.trim();
3952
3953                // Stop if we hit an empty line or another list item or special block
3954                if is_block_boundary(next_trimmed) {
3955                    break;
3956                }
3957
3958                // Check if this line is indented (continuation of list item)
3959                let next_indent = next_line.len() - next_line.trim_start().len();
3960                if next_indent >= min_continuation_indent {
3961                    // This is a continuation line - add its content
3962                    // Preserve hard breaks while trimming excessive whitespace
3963                    let trimmed_start = next_line.trim_start();
3964                    list_content.push(trim_preserving_hard_break(trimmed_start));
3965                    i += 1;
3966                } else {
3967                    // Not indented enough, not part of this list item
3968                    break;
3969                }
3970            }
3971
3972            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
3973            // Hard breaks should prevent joining with the next line
3974            let combined_content = if options.preserve_breaks {
3975                list_content[0].clone()
3976            } else {
3977                // Check if any lines have hard breaks - if so, preserve the structure
3978                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
3979                if has_hard_breaks {
3980                    // Don't join lines with hard breaks - keep them separate with newlines
3981                    list_content.join("\n")
3982                } else {
3983                    // No hard breaks, safe to join with spaces
3984                    list_content.join(" ")
3985                }
3986            };
3987
3988            // Calculate the proper indentation for continuation lines
3989            let trimmed_marker = marker;
3990            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
3991                // Cap the relative indent (past the nesting level) to max_indent,
3992                // then add back the nesting indent so nested items stay correct
3993                indent + (content_start - indent).min(max_indent)
3994            } else {
3995                content_start
3996            };
3997
3998            // Adjust line length to account for list marker and space
3999            let prefix_length = indent + trimmed_marker.len() + 1;
4000
4001            // Create adjusted options with reduced line length
4002            let adjusted_options = ReflowOptions {
4003                line_length: options.line_length.saturating_sub(prefix_length),
4004                ..options.clone()
4005            };
4006
4007            let reflowed = reflow_line(&combined_content, &adjusted_options);
4008            for (j, reflowed_line) in reflowed.iter().enumerate() {
4009                if j == 0 {
4010                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
4011                } else {
4012                    // Continuation lines aligned with text after marker
4013                    let continuation_indent = " ".repeat(continuation_spaces);
4014                    result.push(format!("{continuation_indent}{reflowed_line}"));
4015                }
4016            }
4017            continue;
4018        }
4019
4020        // Preserve tables
4021        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
4022            result.push(line.to_string());
4023            i += 1;
4024            continue;
4025        }
4026
4027        // Preserve reference definitions
4028        if trimmed.starts_with('[') && line.contains("]:") {
4029            result.push(line.to_string());
4030            i += 1;
4031            continue;
4032        }
4033
4034        // Preserve definition list items (extended markdown)
4035        if is_definition_list_item(trimmed) {
4036            result.push(line.to_string());
4037            i += 1;
4038            continue;
4039        }
4040
4041        // Check if this is a single line that doesn't need processing
4042        let mut is_single_line_paragraph = true;
4043        if i + 1 < lines.len() {
4044            let next_trimmed = lines[i + 1].trim();
4045            // Check if next line continues this paragraph
4046            if !is_block_boundary(next_trimmed) {
4047                is_single_line_paragraph = false;
4048            }
4049        }
4050
4051        // If it's a single line that fits, just add it as-is
4052        if is_single_line_paragraph && line_fits(line, options) {
4053            result.push(line.to_string());
4054            i += 1;
4055            continue;
4056        }
4057
4058        // For regular paragraphs, collect consecutive lines
4059        let mut paragraph_parts = Vec::new();
4060        let mut current_part = vec![line];
4061        i += 1;
4062
4063        // If preserve_breaks is true, treat each line separately
4064        if options.preserve_breaks {
4065            // Don't collect consecutive lines - just reflow this single line
4066            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
4067                Some("\\")
4068            } else if line.ends_with("  ") {
4069                Some("  ")
4070            } else {
4071                None
4072            };
4073            let reflowed = reflow_line(line, options);
4074
4075            // Preserve hard breaks (two trailing spaces or backslash)
4076            if let Some(break_marker) = hard_break_type {
4077                if !reflowed.is_empty() {
4078                    let mut reflowed_with_break = reflowed;
4079                    let last_idx = reflowed_with_break.len() - 1;
4080                    if !has_hard_break(&reflowed_with_break[last_idx]) {
4081                        reflowed_with_break[last_idx].push_str(break_marker);
4082                    }
4083                    result.extend(reflowed_with_break);
4084                }
4085            } else {
4086                result.extend(reflowed);
4087            }
4088        } else {
4089            // Original behavior: collect consecutive lines into a paragraph
4090            while i < lines.len() {
4091                let prev_line = if !current_part.is_empty() {
4092                    current_part.last().unwrap()
4093                } else {
4094                    ""
4095                };
4096                let next_line = lines[i];
4097                let next_trimmed = next_line.trim();
4098
4099                // Stop at empty lines or special blocks
4100                if is_block_boundary(next_trimmed) {
4101                    break;
4102                }
4103
4104                // Check if previous line ends with hard break (two spaces or backslash)
4105                // or is a complete sentence in sentence_per_line mode
4106                let prev_trimmed = prev_line.trim();
4107                let abbreviations = get_abbreviations(&options.abbreviations);
4108                let ends_with_sentence = (prev_trimmed.ends_with('.')
4109                    || prev_trimmed.ends_with('!')
4110                    || prev_trimmed.ends_with('?')
4111                    || prev_trimmed.ends_with(".*")
4112                    || prev_trimmed.ends_with("!*")
4113                    || prev_trimmed.ends_with("?*")
4114                    || prev_trimmed.ends_with("._")
4115                    || prev_trimmed.ends_with("!_")
4116                    || prev_trimmed.ends_with("?_")
4117                    // Quote-terminated sentences (straight and curly quotes)
4118                    || prev_trimmed.ends_with(".\"")
4119                    || prev_trimmed.ends_with("!\"")
4120                    || prev_trimmed.ends_with("?\"")
4121                    || prev_trimmed.ends_with(".'")
4122                    || prev_trimmed.ends_with("!'")
4123                    || prev_trimmed.ends_with("?'")
4124                    || prev_trimmed.ends_with(".\u{201D}")
4125                    || prev_trimmed.ends_with("!\u{201D}")
4126                    || prev_trimmed.ends_with("?\u{201D}")
4127                    || prev_trimmed.ends_with(".\u{2019}")
4128                    || prev_trimmed.ends_with("!\u{2019}")
4129                    || prev_trimmed.ends_with("?\u{2019}"))
4130                    && !text_ends_with_abbreviation(
4131                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
4132                        &abbreviations,
4133                    );
4134
4135                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
4136                    // Start a new part after hard break or complete sentence
4137                    paragraph_parts.push(current_part.join(" "));
4138                    current_part = vec![next_line];
4139                } else {
4140                    current_part.push(next_line);
4141                }
4142                i += 1;
4143            }
4144
4145            // Add the last part
4146            if !current_part.is_empty() {
4147                if current_part.len() == 1 {
4148                    // Single line, don't add trailing space
4149                    paragraph_parts.push(current_part[0].to_string());
4150                } else {
4151                    paragraph_parts.push(current_part.join(" "));
4152                }
4153            }
4154
4155            // Reflow each part separately, preserving hard breaks
4156            for (j, part) in paragraph_parts.iter().enumerate() {
4157                let reflowed = reflow_line(part, options);
4158                result.extend(reflowed);
4159
4160                // Preserve hard break by ensuring last line of part ends with hard break marker
4161                // Use two spaces as the default hard break format for reflows
4162                // But don't add hard breaks in sentence_per_line mode - lines are already separate
4163                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
4164                    let last_idx = result.len() - 1;
4165                    if !has_hard_break(&result[last_idx]) {
4166                        result[last_idx].push_str("  ");
4167                    }
4168                }
4169            }
4170        }
4171    }
4172
4173    // Preserve trailing newline if the original content had one
4174    let result_text = result.join("\n");
4175    if content.ends_with('\n') && !result_text.ends_with('\n') {
4176        format!("{result_text}\n")
4177    } else {
4178        result_text
4179    }
4180}
4181
4182/// Information about a reflowed paragraph
4183#[derive(Debug, Clone)]
4184pub struct ParagraphReflow {
4185    /// Starting byte offset of the paragraph in the original content
4186    pub start_byte: usize,
4187    /// Ending byte offset of the paragraph in the original content
4188    pub end_byte: usize,
4189    /// The reflowed text for this paragraph
4190    pub reflowed_text: String,
4191}
4192
4193/// A collected blockquote line used for style-preserving reflow.
4194///
4195/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
4196/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
4197/// rather than constructing the struct directly.
4198#[derive(Debug, Clone)]
4199pub struct BlockquoteLineData {
4200    /// Trimmed content without the `> ` prefix.
4201    pub(crate) content: String,
4202    /// Whether this line carries an explicit blockquote marker.
4203    pub(crate) is_explicit: bool,
4204    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
4205    pub(crate) prefix: Option<String>,
4206}
4207
4208impl BlockquoteLineData {
4209    /// Create an explicit (marker-bearing) blockquote line.
4210    pub fn explicit(content: String, prefix: String) -> Self {
4211        Self {
4212            content,
4213            is_explicit: true,
4214            prefix: Some(prefix),
4215        }
4216    }
4217
4218    /// Create a lazy continuation line (no blockquote marker).
4219    pub fn lazy(content: String) -> Self {
4220        Self {
4221            content,
4222            is_explicit: false,
4223            prefix: None,
4224        }
4225    }
4226}
4227
4228/// Style for blockquote continuation lines after reflow.
4229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4230pub enum BlockquoteContinuationStyle {
4231    Explicit,
4232    Lazy,
4233}
4234
4235/// Determine the continuation style for a blockquote paragraph from its collected lines.
4236///
4237/// The first line is always explicit (it carries the marker), so only continuation
4238/// lines (index 1+) are counted. Ties resolve to `Explicit`.
4239///
4240/// When the slice has only one element (no continuation lines to inspect), both
4241/// counts are zero and the tie-breaking rule returns `Explicit`.
4242pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
4243    let mut explicit_count = 0usize;
4244    let mut lazy_count = 0usize;
4245
4246    for line in lines.iter().skip(1) {
4247        if line.is_explicit {
4248            explicit_count += 1;
4249        } else {
4250            lazy_count += 1;
4251        }
4252    }
4253
4254    if explicit_count > 0 && lazy_count == 0 {
4255        BlockquoteContinuationStyle::Explicit
4256    } else if lazy_count > 0 && explicit_count == 0 {
4257        BlockquoteContinuationStyle::Lazy
4258    } else if explicit_count >= lazy_count {
4259        BlockquoteContinuationStyle::Explicit
4260    } else {
4261        BlockquoteContinuationStyle::Lazy
4262    }
4263}
4264
4265/// Determine the dominant blockquote prefix for a paragraph.
4266///
4267/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
4268/// first appearance. Falls back to `fallback` when no explicit lines are present.
4269pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
4270    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
4271
4272    for (idx, line) in lines.iter().enumerate() {
4273        let Some(prefix) = line.prefix.as_ref() else {
4274            continue;
4275        };
4276        counts
4277            .entry(prefix.clone())
4278            .and_modify(|entry| entry.0 += 1)
4279            .or_insert((1, idx));
4280    }
4281
4282    counts
4283        .into_iter()
4284        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
4285            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
4286        })
4287        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
4288}
4289
4290/// Whether a reflowed blockquote content line must carry an explicit prefix.
4291///
4292/// Lines that would start a new block structure (headings, fences, lists, etc.)
4293/// cannot safely use lazy continuation syntax.
4294pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
4295    let trimmed = content_line.trim_start();
4296    trimmed.starts_with('>')
4297        || trimmed.starts_with('#')
4298        || trimmed.starts_with("```")
4299        || trimmed.starts_with("~~~")
4300        || is_unordered_list_marker(trimmed)
4301        || is_numbered_list_item(trimmed)
4302        || is_horizontal_rule(trimmed)
4303        || is_definition_list_item(trimmed)
4304        || (trimmed.starts_with('[') && trimmed.contains("]:"))
4305        || trimmed.starts_with(":::")
4306        || (trimmed.starts_with('<')
4307            && !trimmed.starts_with("<http")
4308            && !trimmed.starts_with("<https")
4309            && !trimmed.starts_with("<mailto:"))
4310}
4311
4312/// Reflow blockquote content lines and apply continuation style.
4313///
4314/// Segments separated by hard breaks are reflowed independently. The output lines
4315/// receive blockquote prefixes according to `continuation_style`: the first line and
4316/// any line that would start a new block structure always get an explicit prefix;
4317/// other lines follow the detected style.
4318///
4319/// Returns the styled, reflowed lines (without a trailing newline).
4320pub fn reflow_blockquote_content(
4321    lines: &[BlockquoteLineData],
4322    explicit_prefix: &str,
4323    continuation_style: BlockquoteContinuationStyle,
4324    options: &ReflowOptions,
4325) -> Vec<String> {
4326    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
4327    let segments = split_into_segments_strs(&content_strs);
4328    let mut reflowed_content_lines: Vec<String> = Vec::new();
4329
4330    for segment in segments {
4331        let hard_break_type = segment.last().and_then(|&line| {
4332            let line = line.strip_suffix('\r').unwrap_or(line);
4333            if line.ends_with('\\') {
4334                Some("\\")
4335            } else if line.ends_with("  ") {
4336                Some("  ")
4337            } else {
4338                None
4339            }
4340        });
4341
4342        let pieces: Vec<&str> = segment
4343            .iter()
4344            .map(|&line| {
4345                if let Some(l) = line.strip_suffix('\\') {
4346                    l.trim_end()
4347                } else if let Some(l) = line.strip_suffix("  ") {
4348                    l.trim_end()
4349                } else {
4350                    line.trim_end()
4351                }
4352            })
4353            .collect();
4354
4355        let segment_text = pieces.join(" ");
4356        let segment_text = segment_text.trim();
4357        if segment_text.is_empty() {
4358            continue;
4359        }
4360
4361        let mut reflowed = reflow_line(segment_text, options);
4362        if let Some(break_marker) = hard_break_type
4363            && !reflowed.is_empty()
4364        {
4365            let last_idx = reflowed.len() - 1;
4366            if !has_hard_break(&reflowed[last_idx]) {
4367                reflowed[last_idx].push_str(break_marker);
4368            }
4369        }
4370        reflowed_content_lines.extend(reflowed);
4371    }
4372
4373    let mut styled_lines: Vec<String> = Vec::new();
4374    for (idx, line) in reflowed_content_lines.iter().enumerate() {
4375        let force_explicit = idx == 0
4376            || continuation_style == BlockquoteContinuationStyle::Explicit
4377            || should_force_explicit_blockquote_line(line);
4378        if force_explicit {
4379            styled_lines.push(format!("{explicit_prefix}{line}"));
4380        } else {
4381            styled_lines.push(line.clone());
4382        }
4383    }
4384
4385    styled_lines
4386}
4387
4388fn is_blockquote_content_boundary(content: &str) -> bool {
4389    let trimmed = content.trim();
4390    trimmed.is_empty()
4391        || is_block_boundary(trimmed)
4392        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
4393        || trimmed.starts_with(":::")
4394        || crate::utils::is_template_directive_only(content)
4395        || is_standalone_attr_list(content)
4396        || is_snippet_block_delimiter(content)
4397}
4398
4399fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
4400    let mut segments = Vec::new();
4401    let mut current = Vec::new();
4402
4403    for &line in lines {
4404        current.push(line);
4405        if has_hard_break(line) {
4406            segments.push(current);
4407            current = Vec::new();
4408        }
4409    }
4410
4411    if !current.is_empty() {
4412        segments.push(current);
4413    }
4414
4415    segments
4416}
4417
4418fn reflow_blockquote_paragraph_at_line(
4419    content: &str,
4420    lines: &[&str],
4421    target_idx: usize,
4422    options: &ReflowOptions,
4423) -> Option<ParagraphReflow> {
4424    let mut anchor_idx = target_idx;
4425    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
4426        parsed.nesting_level
4427    } else {
4428        let mut found = None;
4429        let mut idx = target_idx;
4430        loop {
4431            if lines[idx].trim().is_empty() {
4432                break;
4433            }
4434            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
4435                found = Some((idx, parsed.nesting_level));
4436                break;
4437            }
4438            if idx == 0 {
4439                break;
4440            }
4441            idx -= 1;
4442        }
4443        let (idx, level) = found?;
4444        anchor_idx = idx;
4445        level
4446    };
4447
4448    // Expand backward to capture prior quote content at the same nesting level.
4449    let mut para_start = anchor_idx;
4450    while para_start > 0 {
4451        let prev_idx = para_start - 1;
4452        let prev_line = lines[prev_idx];
4453
4454        if prev_line.trim().is_empty() {
4455            break;
4456        }
4457
4458        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
4459            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4460                break;
4461            }
4462            para_start = prev_idx;
4463            continue;
4464        }
4465
4466        let prev_lazy = prev_line.trim_start();
4467        if is_blockquote_content_boundary(prev_lazy) {
4468            break;
4469        }
4470        para_start = prev_idx;
4471    }
4472
4473    // Lazy continuation cannot precede the first explicit marker.
4474    while para_start < lines.len() {
4475        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
4476            para_start += 1;
4477            continue;
4478        };
4479        target_level = parsed.nesting_level;
4480        break;
4481    }
4482
4483    if para_start >= lines.len() || para_start > target_idx {
4484        return None;
4485    }
4486
4487    // Collect explicit lines at target level and lazy continuation lines.
4488    // Each entry is (original_line_idx, BlockquoteLineData).
4489    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
4490    let mut idx = para_start;
4491    while idx < lines.len() {
4492        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
4493            break;
4494        }
4495
4496        let line = lines[idx];
4497        if line.trim().is_empty() {
4498            break;
4499        }
4500
4501        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
4502            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
4503                break;
4504            }
4505            collected.push((
4506                idx,
4507                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
4508            ));
4509            idx += 1;
4510            continue;
4511        }
4512
4513        let lazy_content = line.trim_start();
4514        if is_blockquote_content_boundary(lazy_content) {
4515            break;
4516        }
4517
4518        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
4519        idx += 1;
4520    }
4521
4522    if collected.is_empty() {
4523        return None;
4524    }
4525
4526    let para_end = collected[collected.len() - 1].0;
4527    if target_idx < para_start || target_idx > para_end {
4528        return None;
4529    }
4530
4531    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
4532
4533    let fallback_prefix = line_data
4534        .iter()
4535        .find_map(|d| d.prefix.clone())
4536        .unwrap_or_else(|| "> ".to_string());
4537    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
4538    let continuation_style = blockquote_continuation_style(&line_data);
4539
4540    let adjusted_line_length = options
4541        .line_length
4542        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
4543        .max(1);
4544
4545    let adjusted_options = ReflowOptions {
4546        line_length: adjusted_line_length,
4547        ..options.clone()
4548    };
4549
4550    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
4551
4552    if styled_lines.is_empty() {
4553        return None;
4554    }
4555
4556    // Calculate byte offsets.
4557    let mut start_byte = 0;
4558    for line in lines.iter().take(para_start) {
4559        start_byte += line.len() + 1;
4560    }
4561
4562    let mut end_byte = start_byte;
4563    for line in lines.iter().take(para_end + 1).skip(para_start) {
4564        end_byte += line.len() + 1;
4565    }
4566
4567    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4568    if !includes_trailing_newline {
4569        end_byte -= 1;
4570    }
4571
4572    let reflowed_joined = styled_lines.join("\n");
4573    let reflowed_text = if includes_trailing_newline {
4574        if reflowed_joined.ends_with('\n') {
4575            reflowed_joined
4576        } else {
4577            format!("{reflowed_joined}\n")
4578        }
4579    } else if reflowed_joined.ends_with('\n') {
4580        reflowed_joined.trim_end_matches('\n').to_string()
4581    } else {
4582        reflowed_joined
4583    };
4584
4585    Some(ParagraphReflow {
4586        start_byte,
4587        end_byte,
4588        reflowed_text,
4589    })
4590}
4591
4592/// Reflow a single paragraph at the specified line number
4593///
4594/// This function finds the paragraph containing the given line number,
4595/// reflows it according to the specified line length, and returns
4596/// information about the paragraph location and its reflowed text.
4597///
4598/// # Arguments
4599///
4600/// * `content` - The full document content
4601/// * `line_number` - The 1-based line number within the paragraph to reflow
4602/// * `line_length` - The target line length for reflowing
4603///
4604/// # Returns
4605///
4606/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
4607/// or `None` if the line number is out of bounds or the content at that
4608/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
4609pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
4610    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
4611}
4612
4613/// Reflow a paragraph at the given line with a specific length mode.
4614pub fn reflow_paragraph_at_line_with_mode(
4615    content: &str,
4616    line_number: usize,
4617    line_length: usize,
4618    length_mode: ReflowLengthMode,
4619) -> Option<ParagraphReflow> {
4620    let options = ReflowOptions {
4621        line_length,
4622        length_mode,
4623        ..Default::default()
4624    };
4625    reflow_paragraph_at_line_with_options(content, line_number, &options)
4626}
4627
4628/// Reflow a paragraph at the given line using the provided options.
4629///
4630/// This is the canonical implementation used by both the rule's fix mode and the
4631/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
4632/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
4633///
4634/// # Returns
4635///
4636/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
4637/// if the line is out of bounds or sits inside a non-reflow-able construct.
4638pub fn reflow_paragraph_at_line_with_options(
4639    content: &str,
4640    line_number: usize,
4641    options: &ReflowOptions,
4642) -> Option<ParagraphReflow> {
4643    if line_number == 0 {
4644        return None;
4645    }
4646
4647    let lines: Vec<&str> = content.lines().collect();
4648
4649    // Check if line number is valid (1-based)
4650    if line_number > lines.len() {
4651        return None;
4652    }
4653
4654    let target_idx = line_number - 1; // Convert to 0-based
4655    let target_line = lines[target_idx];
4656    let trimmed = target_line.trim();
4657
4658    // Handle blockquote paragraphs (including lazy continuation lines) with
4659    // style-preserving output.
4660    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
4661        return Some(blockquote_reflow);
4662    }
4663
4664    // Don't reflow special blocks
4665    if is_paragraph_boundary(trimmed, target_line) {
4666        return None;
4667    }
4668
4669    // Find paragraph start - scan backward until blank line or special block
4670    let mut para_start = target_idx;
4671    while para_start > 0 {
4672        let prev_idx = para_start - 1;
4673        let prev_line = lines[prev_idx];
4674        let prev_trimmed = prev_line.trim();
4675
4676        // Stop at blank line or special blocks
4677        if is_paragraph_boundary(prev_trimmed, prev_line) {
4678            break;
4679        }
4680
4681        para_start = prev_idx;
4682    }
4683
4684    // Find paragraph end - scan forward until blank line or special block
4685    let mut para_end = target_idx;
4686    while para_end + 1 < lines.len() {
4687        let next_idx = para_end + 1;
4688        let next_line = lines[next_idx];
4689        let next_trimmed = next_line.trim();
4690
4691        // Stop at blank line or special blocks
4692        if is_paragraph_boundary(next_trimmed, next_line) {
4693            break;
4694        }
4695
4696        para_end = next_idx;
4697    }
4698
4699    // Extract paragraph lines
4700    let paragraph_lines = &lines[para_start..=para_end];
4701
4702    // Calculate byte offsets
4703    let mut start_byte = 0;
4704    for line in lines.iter().take(para_start) {
4705        start_byte += line.len() + 1; // +1 for newline
4706    }
4707
4708    let mut end_byte = start_byte;
4709    for line in paragraph_lines {
4710        end_byte += line.len() + 1; // +1 for newline
4711    }
4712
4713    // Track whether the byte range includes a trailing newline
4714    // (it doesn't if this is the last line and the file doesn't end with newline)
4715    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
4716
4717    // Adjust end_byte if the last line doesn't have a newline
4718    if !includes_trailing_newline {
4719        end_byte -= 1;
4720    }
4721
4722    // Join paragraph lines and reflow
4723    let paragraph_text = paragraph_lines.join("\n");
4724
4725    // Reflow the paragraph using reflow_markdown to handle it properly
4726    let reflowed = reflow_markdown(&paragraph_text, options);
4727
4728    // Ensure reflowed text matches whether the byte range includes a trailing newline
4729    // This is critical: if the range includes a newline, the replacement must too,
4730    // otherwise the next line will get appended to the reflowed paragraph
4731    let reflowed_text = if includes_trailing_newline {
4732        // Range includes newline - ensure reflowed text has one
4733        if reflowed.ends_with('\n') {
4734            reflowed
4735        } else {
4736            format!("{reflowed}\n")
4737        }
4738    } else {
4739        // Range doesn't include newline - ensure reflowed text doesn't have one
4740        if reflowed.ends_with('\n') {
4741            reflowed.trim_end_matches('\n').to_string()
4742        } else {
4743            reflowed
4744        }
4745    };
4746
4747    Some(ParagraphReflow {
4748        start_byte,
4749        end_byte,
4750        reflowed_text,
4751    })
4752}
4753/// Decomposes a raw inline code span string into its inner content and backtick marker.
4754///
4755/// For example, `decompose_code_span("`code`")` returns `Some(("code", "`"))`.
4756/// If the input is not a valid code span (e.g., it doesn't start and end with the
4757/// same number of backticks), returns `None`.
4758fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
4759    let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
4760    if marker_len == 0 {
4761        return None;
4762    }
4763    let marker = &raw[..marker_len];
4764    if raw.len() < marker_len * 2 {
4765        return None;
4766    }
4767    let content = &raw[marker_len..raw.len() - marker_len];
4768    Some((content, marker))
4769}
4770
4771#[cfg(test)]
4772mod tests {
4773    use super::*;
4774
4775    /// `preserves_content` is the last line of defense against a reflow writing
4776    /// corrupted prose into a file, so it has to actually reject the ways a
4777    /// reflow can go wrong - not merely accept the ways it can go right.
4778    #[test]
4779    fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
4780        let accepted: &[(&str, &[&str])] = &[
4781            ("one two three", &["one two three"]),
4782            ("one two three", &["one two", "three"]),
4783            ("one two three", &["one", "two", "three"]),
4784            // Collapsing runs of whitespace and dropping trailing whitespace
4785            ("one   two  ", &["one two"]),
4786            // A script written without spaces has to break somewhere
4787            ("日本語のテキスト", &["日本語の", "テキスト"]),
4788            // Markers move to the line their content moved to
4789            ("_First. Second._", &["_First.", "Second._"]),
4790        ];
4791        for (original, reflowed) in accepted {
4792            let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4793            assert!(
4794                preserves_content(original, &reflowed),
4795                "{original:?} -> {reflowed:?} only moves whitespace"
4796            );
4797        }
4798
4799        let rejected: &[(&str, &[&str])] = &[
4800            // Dropped
4801            ("one two three", &["one two"]),
4802            // Invented
4803            ("one two", &["one two three"]),
4804            // Reordered
4805            ("one two", &["two one"]),
4806            // Duplicated
4807            ("_First. Second._", &["_First._", "_Second._"]),
4808            // Two words glued into one
4809            ("alpha and beta", &["alpha", "andbeta"]),
4810            // A space deleted around punctuation
4811            ("mot suivant : autre", &["mot suivant: autre"]),
4812        ];
4813        for (original, reflowed) in rejected {
4814            let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
4815            assert!(
4816                !preserves_content(original, &reflowed),
4817                "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
4818            );
4819        }
4820    }
4821
4822    /// A rejected reflow leaves the line alone rather than writing the damage.
4823    #[test]
4824    fn reflow_line_falls_back_to_the_input_when_content_would_change() {
4825        let options = ReflowOptions {
4826            line_length: 40,
4827            ..Default::default()
4828        };
4829        let line = "one two three four five six seven eight nine ten";
4830
4831        assert!(preserves_content(line, &reflow_line(line, &options)));
4832        assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
4833    }
4834
4835    #[test]
4836    fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
4837        // A single line of thousands of words once drove `cascade_split_line`
4838        // into deep recursion (stack overflow / hang). The iterative version
4839        // must complete and split it into many lines that each fit the width and
4840        // that together preserve every word. The test finishing at all is the
4841        // core assertion (no stack overflow); the content checks guard behavior.
4842        let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
4843        let line = words.join(" ");
4844
4845        let options = ReflowOptions {
4846            line_length: 80,
4847            length_mode: ReflowLengthMode::Chars,
4848            ..Default::default()
4849        };
4850        let out = cascade_split_line(&line, &options);
4851
4852        assert!(out.len() > 1, "a very long line should split into many lines");
4853        for segment in &out {
4854            assert!(
4855                display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
4856                "each wrapped line should fit the width (or be a single unbreakable token)"
4857            );
4858        }
4859        // Every original word survives, in order.
4860        let rejoined = out.join(" ");
4861        let original_words: Vec<&str> = line.split(' ').collect();
4862        let result_words: Vec<&str> = rejoined.split_whitespace().collect();
4863        assert_eq!(original_words, result_words, "reflow must preserve all words in order");
4864    }
4865
4866    /// Unit test for private helper function text_ends_with_abbreviation()
4867    ///
4868    /// This test stays inline because it tests a private function.
4869    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
4870    #[test]
4871    fn test_helper_function_text_ends_with_abbreviation() {
4872        // Test the helper function directly
4873        let abbreviations = get_abbreviations(&None);
4874
4875        // True cases - built-in abbreviations (titles and i.e./e.g.)
4876        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
4877        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
4878        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
4879        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
4880        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
4881        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
4882        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
4883        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
4884
4885        // False cases - NOT in built-in list (etc doesn't always have period)
4886        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
4887        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
4888        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
4889        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
4890        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
4891        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
4892        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
4893        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
4894        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
4895        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
4896    }
4897
4898    #[test]
4899    fn test_footnote_after_period_splits_sentence() {
4900        // A footnote reference glued to the period (no space) must not swallow
4901        // the sentence boundary; the reference stays attached to the sentence
4902        // it annotates.
4903        let text = "First sentence.[^1] Second sentence.";
4904        let sentences = split_into_sentences(text, None);
4905        assert_eq!(
4906            sentences,
4907            vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
4908            "footnote glued to the period should keep the boundary and stay attached to the first sentence"
4909        );
4910    }
4911
4912    #[test]
4913    fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
4914        // Multiple footnote references glued back-to-back after the period.
4915        let text = "Notes here.[^1][^2] Second sentence.";
4916        let sentences = split_into_sentences(text, None);
4917        assert_eq!(
4918            sentences,
4919            vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
4920        );
4921    }
4922
4923    #[test]
4924    fn test_footnote_before_period_still_splits_sentence() {
4925        // Control: a footnote reference before the period was already followed
4926        // by a space, so this boundary worked before this fix and must keep
4927        // working.
4928        let text = "Annotation here[^1]. Second sentence.";
4929        let sentences = split_into_sentences(text, None);
4930        assert_eq!(
4931            sentences,
4932            vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
4933        );
4934    }
4935
4936    #[test]
4937    fn test_mid_sentence_footnote_does_not_split() {
4938        // A footnote reference not glued to sentence-ending punctuation must not
4939        // introduce a spurious boundary at the bracket itself.
4940        let text = "The system word[^1] more words. Next sentence.";
4941        let sentences = split_into_sentences(text, None);
4942        assert_eq!(
4943            sentences,
4944            vec![
4945                "The system word[^1] more words.".to_string(),
4946                "Next sentence.".to_string()
4947            ]
4948        );
4949    }
4950
4951    #[test]
4952    fn test_bare_numeric_bracket_after_period_does_not_split() {
4953        // A bare `[1]` is link/citation-like text, not footnote syntax; the fix
4954        // is scoped to `[^label]` only.
4955        let text = "Citation here.[1] Second sentence.";
4956        let sentences = split_into_sentences(text, None);
4957        assert_eq!(
4958            sentences,
4959            vec![text.to_string()],
4960            "a bare numeric bracket must not be treated as a sentence boundary"
4961        );
4962    }
4963
4964    #[test]
4965    fn test_footnote_glued_to_following_word_does_not_split() {
4966        // No whitespace after the footnote reference means there is nowhere a
4967        // next sentence can start, so this must not be treated as a boundary.
4968        let text = "First sentence.[^1]Continued glued text.";
4969        let sentences = split_into_sentences(text, None);
4970        assert_eq!(sentences, vec![text.to_string()]);
4971    }
4972
4973    #[test]
4974    fn test_footnote_at_end_of_text_is_preserved() {
4975        // A footnote reference at the very end of the text has nothing after it
4976        // to split off; it is preserved as part of the single trailing sentence.
4977        let text = "Sentence.[^1]";
4978        let sentences = split_into_sentences(text, None);
4979        assert_eq!(sentences, vec![text.to_string()]);
4980    }
4981
4982    #[test]
4983    fn test_abbreviation_before_footnote_does_not_split() {
4984        // The existing abbreviation guard must still apply when a footnote
4985        // reference immediately follows the abbreviation's period.
4986        let text = "See the notes, e.g.[^1] this one.";
4987        let sentences = split_into_sentences(text, None);
4988        assert_eq!(
4989            sentences,
4990            vec![text.to_string()],
4991            "e.g. is an abbreviation, not a sentence boundary"
4992        );
4993    }
4994
4995    #[test]
4996    fn sentence_boundary_never_falls_inside_an_atomic_construct() {
4997        // Each construct holds text that reads like a sentence boundary
4998        // (`. ` followed by a capital) but is one unit to the renderer: a
4999        // break inside it rewrites the document. The boundary after each
5000        // construct is real and must still split, so a construct that simply
5001        // silenced the splitter would fail here too.
5002        let cases = [
5003            "Prefix [link. Still link](https://example.com) tail. Next sentence.",
5004            "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
5005            "Prefix [text](url \"Title. More\") tail. Next sentence.",
5006            "Prefix ![alt. Alt](img.png) tail. Next sentence.",
5007            "Prefix [ref text. More][ref] tail. Next sentence.",
5008            "Prefix [collapsed. More][] tail. Next sentence.",
5009            "Prefix [[Page name. Title]] tail. Next sentence.",
5010            "Prefix $x. Y$ tail. Next sentence.",
5011            "Prefix $$x. Y$$ tail. Next sentence.",
5012            "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
5013            "Prefix `code. Still code` tail. Next sentence.",
5014        ];
5015        for text in cases {
5016            let sentences = split_into_sentences(text, None);
5017            let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
5018            assert_eq!(
5019                sentences,
5020                vec![format!("{head} tail."), tail.to_string()],
5021                "input {text:?}"
5022            );
5023        }
5024
5025        // A bare `[text]` is a link only when its label is defined. Defined,
5026        // or with the definitions unknown, it is held whole like the rest;
5027        // known undefined, it is prose and the boundary inside it is real.
5028        let text = "Prefix [shortcut. More] tail. Next sentence.";
5029        let whole = vec![
5030            "Prefix [shortcut. More] tail.".to_string(),
5031            "Next sentence.".to_string(),
5032        ];
5033        let defined = HashSet::from(["shortcut. more".to_string()]);
5034        assert_eq!(split_into_sentences(text, Some(&defined)), whole);
5035        assert_eq!(split_into_sentences(text, None), whole);
5036        assert_eq!(
5037            split_into_sentences(text, Some(&HashSet::new())),
5038            vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
5039        );
5040    }
5041
5042    #[test]
5043    fn a_sentence_may_open_with_a_link_or_image() {
5044        // The next sentence's first letter sits behind the link opener; a
5045        // capital there is a capital start. The reflow already emits the text
5046        // before the link as its own line, so the check has to count the same
5047        // boundary or it never asks for that split.
5048        for text in [
5049            "Opening sentence. [First. Second](https://example.com)",
5050            "Opening sentence. ![First. Second](img.png)",
5051            "Opening sentence. [[First. Second]]",
5052            "Opening sentence. [[first-note|First. Second]]",
5053            "Opening sentence. [Ref link][ref]",
5054            // A link whose text is an image opens with the image's alt text,
5055            // one construct inside another; each is walked into at its own start.
5056            "Opening sentence. [![First image](img.png)](url) continues.",
5057            "Opening sentence. [![First image](img.png)][ref] continues.",
5058            // The nested image may be a reference image; its full and
5059            // collapsed forms are images whether or not the label is defined.
5060            "Opening sentence. [![First image][img]](url) continues.",
5061            "Opening sentence. [![First image][]](url) continues.",
5062            "Opening sentence. [![First image][img]][ref] continues.",
5063        ] {
5064            let (head, tail) = text.split_once(". ").expect("case has a boundary");
5065            assert_eq!(
5066                split_into_sentences(text, None),
5067                vec![format!("{head}."), tail.to_string()],
5068                "input {text:?}"
5069            );
5070        }
5071        // A shortcut reference image nested in a link is an image only when
5072        // its label is defined, exactly as at the top level.
5073        let text = "Opening sentence. [![First image]](url) continues.";
5074        let defined = HashSet::from(["first image".to_string()]);
5075        assert_eq!(
5076            split_into_sentences(text, Some(&defined)),
5077            vec!["Opening sentence.", "[![First image]](url) continues."]
5078        );
5079        assert_eq!(
5080            split_into_sentences(text, Some(&HashSet::new())),
5081            vec![text.to_string()],
5082            "an undefined shortcut is bracketed text, and `!` opens no sentence"
5083        );
5084        // The nested image's alt text is what has to be capitalized: the same
5085        // link with a lowercase alt opens no sentence.
5086        assert_eq!(
5087            split_into_sentences("Opening sentence. [![first image](img.png)](url) continues.", None),
5088            vec!["Opening sentence. [![first image](img.png)](url) continues."]
5089        );
5090        // A bare `[text]` whose label is defined is a link too, and its text
5091        // opens the sentence the same way.
5092        let defined = HashSet::from(["smith 2020".to_string()]);
5093        assert_eq!(
5094            split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined)),
5095            vec!["Claim ends here.", "[Smith 2020] more text."]
5096        );
5097        // Controls: a lowercase link text is no sentence start, and a bracket
5098        // the parse reads as text is not a link opener, so a citation, an
5099        // undefined shortcut, a footnote label or a link left unterminated
5100        // starts no sentence however it is capitalized. The definitions are
5101        // known and empty here, as the rule always supplies them.
5102        let none_defined = HashSet::new();
5103        for text in [
5104            "Opening sentence. [first link](https://example.com) continues.",
5105            "Opening sentence. [[first note]] continues.",
5106            "Opening sentence. [[First Note|first note]] continues.",
5107            "Opening sentence. [[Page continues.",
5108            "Opening sentence. [[First] stray]] continues.",
5109            "Opening sentence. ![first alt](img.png) continues.",
5110            "Opening sentence. [1] is the citation.",
5111            "Opening sentence. [First](unterminated",
5112            "Opening sentence. [First][unterminated",
5113            "Opening sentence. [First] (aside) continues.",
5114            "Claim ends here. [Smith 2020]",
5115            "Claim ends here. [Smith 2020] more text.",
5116            "See the RFC. [RFC] More text.",
5117            "Claim ends here. [^Note] more text.",
5118        ] {
5119            assert_eq!(
5120                split_into_sentences(text, Some(&none_defined)),
5121                vec![text.to_string()],
5122                "input {text:?}"
5123            );
5124        }
5125    }
5126
5127    #[test]
5128    fn link_opener_is_read_off_the_parse() {
5129        // Length of the opener at the start of `text`, or 0 when the parse
5130        // (with the given definitions) finds no link, image or wikilink there.
5131        let len = |text: &str, defs: Option<&HashSet<String>>| {
5132            let chars: Vec<char> = text.chars().collect();
5133            let char_offsets = char_byte_offsets(&chars);
5134            let NestedStructure { links, .. } = sentence_structure(text, defs);
5135            let st = SentenceText {
5136                text,
5137                chars: &chars,
5138                char_offsets: &char_offsets,
5139                links: &links,
5140            };
5141            st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
5142        };
5143        let none = HashSet::new();
5144        assert_eq!(len("[text](url)", Some(&none)), 1);
5145        assert_eq!(
5146            len("[text][ref]", Some(&none)),
5147            1,
5148            "a full reference is a link whether or not defined"
5149        );
5150        assert_eq!(len("[text][]", Some(&none)), 1);
5151        assert_eq!(len("![alt](img.png)", Some(&none)), 2);
5152        assert_eq!(len("[[wiki]]", Some(&none)), 2);
5153        assert_eq!(
5154            len("[[wiki|shown]]", Some(&none)),
5155            7,
5156            "the displayed text starts after the alias pipe"
5157        );
5158        assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
5159        assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
5160        assert_eq!(
5161            len("[[wiki|shown]] [[a|b]]", Some(&none)),
5162            7,
5163            "a pipe past the closing `]]` is not this alias"
5164        );
5165        assert_eq!(
5166            len("[a \\] b](url)", Some(&none)),
5167            1,
5168            "an escaped bracket does not close the text"
5169        );
5170        assert_eq!(
5171            len("[![alt](img)](url)", Some(&none)),
5172            1,
5173            "the outer opener is skipped first"
5174        );
5175        // Text to the parse, so no opener: unterminated links, an unclosed or
5176        // malformed wikilink, a shortcut nothing defines, and a footnote
5177        // reference, which the paragraph-level parse has no definition for.
5178        for text in [
5179            "[^1]",
5180            "[text](unterminated",
5181            "[text][unterminated",
5182            "[text] (url)",
5183            "[[wiki",
5184            "[[wiki]",
5185            "[[First] stray]]",
5186            "[Smith 2020]",
5187            "[Smith 2020] (see also)",
5188            "[unclosed",
5189            "!bang",
5190            "text",
5191        ] {
5192            assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
5193        }
5194        // The same shortcut is a link once its label is defined, or when the
5195        // definitions are unknown.
5196        let smith = HashSet::from(["smith 2020".to_string()]);
5197        assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
5198        assert_eq!(len("[Smith 2020]", None), 1);
5199    }
5200
5201    #[test]
5202    fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
5203        // The check counts a boundary before a link, image or wikilink whose
5204        // text starts a sentence and none before a lowercase one, a bare
5205        // citation or a glued link. The reflow has to break at exactly those
5206        // boundaries: a break the check never counts is a fix that keeps
5207        // reporting, and a boundary the reflow ignores is a line it never
5208        // splits. The definitions are known, as the rule always supplies
5209        // them: `[RFC]` alone is a citation, `[Spec]` a defined shortcut link.
5210        let defined = HashSet::from(["spec".to_string()]);
5211        let options = ReflowOptions {
5212            line_length: 120,
5213            sentence_per_line: true,
5214            defined_references: Some(defined.clone()),
5215            ..Default::default()
5216        };
5217        for (text, expected) in [
5218            (
5219                "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
5220                vec![
5221                    "Claim ends here.",
5222                    "[Smith](https://example.com) more text.",
5223                    "Second sentence.",
5224                ],
5225            ),
5226            (
5227                "Wow! [smith](https://example.com) more text. Second sentence.",
5228                vec!["Wow!", "[smith](https://example.com) more text.", "Second sentence."],
5229            ),
5230            (
5231                "Claim ends here. [smith](https://example.com) more text. Second sentence.",
5232                vec![
5233                    "Claim ends here. [smith](https://example.com) more text.",
5234                    "Second sentence.",
5235                ],
5236            ),
5237            (
5238                "Claim ends here. [smith][ref] more text. Second sentence.",
5239                vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
5240            ),
5241            (
5242                "Claim ends here. ![alt](img.png) more text. Second sentence.",
5243                vec!["Claim ends here. ![alt](img.png) more text.", "Second sentence."],
5244            ),
5245            (
5246                "Claim ends here.[Link](https://example.com) more text. Second sentence.",
5247                vec![
5248                    "Claim ends here.[Link](https://example.com) more text.",
5249                    "Second sentence.",
5250                ],
5251            ),
5252            (
5253                "See the RFC. [RFC] More text. Second sentence.",
5254                vec!["See the RFC. [RFC] More text.", "Second sentence."],
5255            ),
5256            (
5257                "See the spec. [Spec] More text. Second sentence.",
5258                vec!["See the spec.", "[Spec] More text.", "Second sentence."],
5259            ),
5260            (
5261                "See the spec. [spec] more text. Second sentence.",
5262                vec!["See the spec. [spec] more text.", "Second sentence."],
5263            ),
5264            (
5265                "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
5266                vec![
5267                    "Claim ends here.",
5268                    "[[page|Second sentence]] continues.",
5269                    "Third sentence.",
5270                ],
5271            ),
5272            (
5273                "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
5274                vec![
5275                    "Claim ends here. [[Page|second sentence]] continues.",
5276                    "Third sentence.",
5277                ],
5278            ),
5279        ] {
5280            let lines = reflow_line(text, &options);
5281            assert_eq!(lines, expected, "input {text:?}");
5282            // The check counts the same number of sentences on the input as
5283            // the reflow produced lines, and one on each line it produced.
5284            assert_eq!(
5285                split_into_sentences(text, Some(&defined)).len(),
5286                expected.len(),
5287                "check count for {text:?}"
5288            );
5289            for line in &lines {
5290                assert_eq!(
5291                    split_into_sentences(line, Some(&defined)).len(),
5292                    1,
5293                    "line {line:?} of {text:?}"
5294                );
5295            }
5296        }
5297    }
5298
5299    #[test]
5300    fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
5301        // The reflow assembles a line one element at a time and re-splits the
5302        // whole line after each text element, so an atomic element already on
5303        // the line is exposed to the splitter along with the text after it.
5304        let options = ReflowOptions {
5305            line_length: 80,
5306            sentence_per_line: true,
5307            ..Default::default()
5308        };
5309        let lines = reflow_line(
5310            "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
5311            &options,
5312        );
5313        assert_eq!(
5314            lines,
5315            vec![
5316                "Prefix `code. Still code` and [link. Still link](https://example.com) tail.".to_string(),
5317                "Next sentence.".to_string(),
5318            ]
5319        );
5320
5321        let lines = reflow_line(
5322            "Prefix ![alt. Alt](img.png) and [target](<https://example.com/First. Second>) tail. Next sentence.",
5323            &options,
5324        );
5325        assert_eq!(
5326            lines,
5327            vec![
5328                "Prefix ![alt. Alt](img.png) and [target](<https://example.com/First. Second>) tail.".to_string(),
5329                "Next sentence.".to_string(),
5330            ]
5331        );
5332
5333        // Control: a link that carries no boundary of its own leaves the
5334        // surrounding boundaries exactly where they were.
5335        let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
5336        assert_eq!(
5337            lines,
5338            vec![
5339                "First one.".to_string(),
5340                "Then [link](url) second.".to_string(),
5341                "Third one.".to_string(),
5342            ]
5343        );
5344    }
5345
5346    #[test]
5347    fn test_is_unordered_list_marker() {
5348        // Valid unordered list markers
5349        assert!(is_unordered_list_marker("- item"));
5350        assert!(is_unordered_list_marker("* item"));
5351        assert!(is_unordered_list_marker("+ item"));
5352        assert!(is_unordered_list_marker("-")); // lone marker
5353        assert!(is_unordered_list_marker("*"));
5354        assert!(is_unordered_list_marker("+"));
5355
5356        // Not list markers
5357        assert!(!is_unordered_list_marker("---")); // horizontal rule
5358        assert!(!is_unordered_list_marker("***")); // horizontal rule
5359        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
5360        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
5361        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
5362        assert!(!is_unordered_list_marker("-word")); // no space after marker
5363        assert!(!is_unordered_list_marker("")); // empty
5364        assert!(!is_unordered_list_marker("text")); // plain text
5365        assert!(!is_unordered_list_marker("# heading")); // heading
5366    }
5367
5368    #[test]
5369    fn test_is_block_boundary() {
5370        // Block boundaries
5371        assert!(is_block_boundary("")); // empty line
5372        assert!(is_block_boundary("# Heading")); // ATX heading
5373        assert!(is_block_boundary("## Level 2")); // ATX heading
5374        assert!(is_block_boundary("```rust")); // code fence
5375        assert!(is_block_boundary("~~~")); // tilde code fence
5376        assert!(is_block_boundary("> quote")); // blockquote
5377        assert!(is_block_boundary("| cell |")); // table
5378        assert!(is_block_boundary("[link]: http://example.com")); // reference def
5379        assert!(is_block_boundary("---")); // horizontal rule
5380        assert!(is_block_boundary("***")); // horizontal rule
5381        assert!(is_block_boundary("- item")); // unordered list
5382        assert!(is_block_boundary("* item")); // unordered list
5383        assert!(is_block_boundary("+ item")); // unordered list
5384        assert!(is_block_boundary("1. item")); // ordered list
5385        assert!(is_block_boundary("10. item")); // ordered list
5386        assert!(is_block_boundary(": definition")); // definition list
5387        assert!(is_block_boundary(":::")); // div marker
5388        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
5389
5390        // NOT block boundaries (paragraph continuation)
5391        assert!(!is_block_boundary("regular text"));
5392        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
5393        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
5394        assert!(!is_block_boundary("some words here"));
5395    }
5396
5397    #[test]
5398    fn test_definition_list_boundary_in_single_line_paragraph() {
5399        // Verifies that a definition list item after a single-line paragraph
5400        // is treated as a block boundary, not merged into the paragraph
5401        let options = ReflowOptions {
5402            line_length: 80,
5403            ..Default::default()
5404        };
5405        let input = "Term\n: Definition of the term";
5406        let result = reflow_markdown(input, &options);
5407        // The definition list marker should remain on its own line
5408        assert!(
5409            result.contains(": Definition"),
5410            "Definition list item should not be merged into previous line. Got: {result:?}"
5411        );
5412        let lines: Vec<&str> = result.lines().collect();
5413        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
5414        assert_eq!(lines[0], "Term");
5415        assert_eq!(lines[1], ": Definition of the term");
5416    }
5417
5418    #[test]
5419    fn test_is_paragraph_boundary() {
5420        // Core block boundary checks are inherited
5421        assert!(is_paragraph_boundary("# Heading", "# Heading"));
5422        assert!(is_paragraph_boundary("- item", "- item"));
5423        assert!(is_paragraph_boundary(":::", ":::"));
5424        assert!(is_paragraph_boundary(": definition", ": definition"));
5425
5426        // Indented code blocks (≥4 spaces or tab)
5427        assert!(is_paragraph_boundary("code", "    code"));
5428        assert!(is_paragraph_boundary("code", "\tcode"));
5429
5430        // Table rows via is_potential_table_row
5431        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
5432        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
5433
5434        // Not paragraph boundaries
5435        assert!(!is_paragraph_boundary("regular text", "regular text"));
5436        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
5437    }
5438
5439    #[test]
5440    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
5441        // Verifies that div markers (:::) are treated as paragraph boundaries
5442        // in reflow_paragraph_at_line, preventing reflow across div boundaries
5443        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
5444        // Line 3 is the div marker — should not be reflowed
5445        let result = reflow_paragraph_at_line(content, 3, 80);
5446        assert!(result.is_none(), "Div marker line should not be reflowed");
5447    }
5448
5449    #[test]
5450    fn starts_block_construct_detects_block_openers() {
5451        // Bullet list markers: marker char followed by space or end
5452        for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
5453            assert!(starts_block_construct(case), "bullet: {case:?}");
5454        }
5455        // Ordered list markers: only a list numbered 1 with a non-empty first
5456        // item interrupts a paragraph. Leading zeros keep the number 1.
5457        for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
5458            assert!(starts_block_construct(case), "ordered: {case:?}");
5459        }
5460        // Blockquote: `>` needs no following space
5461        for case in ["> quote", ">quote", ">"] {
5462            assert!(starts_block_construct(case), "blockquote: {case:?}");
5463        }
5464        // ATX headings: 1-6 hashes then space or end
5465        for case in ["# heading", "###### h6", "#", "##"] {
5466            assert!(starts_block_construct(case), "heading: {case:?}");
5467        }
5468        // Code fences: 3+ backticks or tildes
5469        for case in ["```", "```rust", "````", "~~~", "~~~text"] {
5470            assert!(starts_block_construct(case), "fence: {case:?}");
5471        }
5472        // Setext underlines and thematic breaks
5473        for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
5474            assert!(starts_block_construct(case), "setext/thematic: {case:?}");
5475        }
5476        // Footnote and link-reference definitions: hoisting one to line start
5477        // reclassifies it and can resolve dangling references elsewhere
5478        for case in [
5479            "[^1]: text",
5480            "[^note]:",
5481            "[ref]: http://example.com",
5482            "[wat]: url follows",
5483        ] {
5484            assert!(starts_block_construct(case), "definition: {case:?}");
5485        }
5486        // Block-level HTML tags (rumdl parser's HTML block classification)
5487        for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
5488            assert!(starts_block_construct(case), "html block: {case:?}");
5489        }
5490    }
5491
5492    #[test]
5493    fn starts_block_construct_allows_ordinary_prose() {
5494        for case in [
5495            "",
5496            "word",
5497            "-5 degrees",
5498            "--flag",
5499            "-item",
5500            "#hashtag",
5501            "####### seven hashes is not a heading",
5502            "1.5 million",
5503            "1234567890. ten digits is not a list marker",
5504            "0000000001. ten digits is not a list marker either",
5505            // A number other than 1 cannot interrupt a paragraph, nor can an
5506            // empty first item, so neither changes the parse at line start.
5507            "2. item",
5508            "7. item",
5509            "0. item",
5510            "42) x",
5511            "123456. item",
5512            "1.",
5513            "1)",
5514            "123456.",
5515            "123456)",
5516            "1.item",
5517            "1:30 pm",
5518            "*emphasis*",
5519            "**bold** text",
5520            "__bold__ text",
5521            "_emphasis_ text",
5522            "`code` span",
5523            "`` double backtick span ``",
5524            "~~strikethrough~~",
5525            "=x",
5526            "== ==",
5527            "(parenthetical)",
5528            "[link](url)",
5529            "[text][ref] more",
5530            "[bracketed] aside",
5531            "[a](b) [ref]: first bracket is a link, not a label",
5532            "[esc\\]: not a close] text",
5533            "<span>inline</span>",
5534            "<b>bold</b>",
5535            "<https://example.com> autolink",
5536            "<mailto:a@b.com>",
5537            "<notarealtag>",
5538        ] {
5539            assert!(!starts_block_construct(case), "prose: {case:?}");
5540        }
5541    }
5542
5543    #[test]
5544    fn merge_block_construct_continuations_merges_marker_led_lines() {
5545        let lines = vec![
5546            "First sentence?".to_string(),
5547            "- looks like a list item".to_string(),
5548            "Second sentence.".to_string(),
5549        ];
5550        assert_eq!(
5551            merge_block_construct_continuations(lines),
5552            vec![
5553                "First sentence? - looks like a list item".to_string(),
5554                "Second sentence.".to_string(),
5555            ]
5556        );
5557
5558        // The first line keeps its position: it replaces the paragraph's
5559        // original start, where the source already established the context.
5560        let lines = vec!["- real list content".to_string(), "continuation".to_string()];
5561        assert_eq!(
5562            merge_block_construct_continuations(lines.clone()),
5563            lines,
5564            "first line must never be merged"
5565        );
5566
5567        // Folding cascades: `1.` alone is inert, but absorbing `[ref]:` makes
5568        // it a list item, so the grown line has to fold back in turn.
5569        let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
5570        assert_eq!(
5571            merge_block_construct_continuations(lines),
5572            vec!["prose 1. [ref]:".to_string()],
5573            "a merge that creates an opener must fold again"
5574        );
5575    }
5576
5577    #[test]
5578    fn wrap_never_starts_a_line_with_a_block_marker() {
5579        let options = ReflowOptions {
5580            line_length: 25,
5581            ..Default::default()
5582        };
5583        // The dash lands exactly at the wrap point; the wrapper must break one
5584        // word earlier so the dash stays mid-line.
5585        let lines = reflow_line(
5586            "Some words here and then - a dash clause that wraps around the limit.",
5587            &options,
5588        );
5589        assert_eq!(
5590            lines,
5591            vec![
5592                "Some words here and",
5593                "then - a dash clause that",
5594                "wraps around the limit."
5595            ]
5596        );
5597
5598        // Every marker category must stay mid-line in wrap mode, whatever the width.
5599        for input in [
5600            "Alpha beta gamma delta epsilon - dash clause here to wrap",
5601            "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
5602            "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
5603            "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
5604            "Alpha beta gamma delta epsilon * star clause here to wrap",
5605            "Alpha beta gamma delta epsilon + plus clause here to wrap",
5606        ] {
5607            for width in 10..40 {
5608                let options = ReflowOptions {
5609                    line_length: width,
5610                    ..Default::default()
5611                };
5612                for line in reflow_line(input, &options) {
5613                    assert!(
5614                        !starts_block_construct(&line),
5615                        "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
5616                    );
5617                }
5618            }
5619        }
5620    }
5621
5622    #[test]
5623    fn sentence_per_line_keeps_block_markers_mid_line() {
5624        let options = ReflowOptions {
5625            line_length: 80,
5626            sentence_per_line: true,
5627            ..Default::default()
5628        };
5629        // A sentence "starting" with a dash must stay attached to the previous
5630        // sentence instead of becoming a list item (issue #728).
5631        let lines = reflow_line(
5632            "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
5633            &options,
5634        );
5635        assert_eq!(
5636            lines,
5637            vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
5638        );
5639
5640        // Same for heading, blockquote, and ordered-list lookalikes.
5641        let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
5642        assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
5643
5644        let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
5645        assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
5646
5647        let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
5648        for line in &lines {
5649            assert!(
5650                !starts_block_construct(line),
5651                "sentence-per-line output opens a block construct: {line:?}"
5652            );
5653        }
5654    }
5655
5656    #[test]
5657    fn inline_math_directly_after_display_math_stays_atomic() {
5658        // The inline-math regex's lookbehind `(?<!\$)` is slice-start-sensitive:
5659        // a search anchored at the cursor accepts a `$` whose real predecessor
5660        // is a `$` (the lookbehind sees nothing before the slice), while a
5661        // cached search anchored earlier sees the `$` and rejects it. After
5662        // display math consumes `$$a$$`, the cursor sits directly after a `$`;
5663        // the match cache must re-search there or `$bb cc dd$` degrades to
5664        // plain text and gets wrapped apart, breaking math rendering.
5665        let options = ReflowOptions {
5666            line_length: 8,
5667            ..Default::default()
5668        };
5669        let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
5670        assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
5671    }
5672
5673    #[test]
5674    fn test_code_span_parsing() {
5675        // 1. Single backtick
5676        let elements = parse_markdown_elements_inner("`code`", false, false, None);
5677        assert_eq!(elements.len(), 1);
5678        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
5679
5680        // 2. Double backtick
5681        let elements = parse_markdown_elements_inner("``code``", false, false, None);
5682        assert_eq!(elements.len(), 1);
5683        assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
5684
5685        // 3. Double backtick with single backtick inside
5686        let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
5687        assert_eq!(elements.len(), 1);
5688        assert!(
5689            matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
5690        );
5691
5692        // 4. Spaces inside
5693        let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
5694        assert_eq!(elements.len(), 1);
5695        assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
5696
5697        // 5. Unclosed backtick (should be parsed as Text)
5698        let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
5699        assert_eq!(elements.len(), 1);
5700        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
5701
5702        // 6. Unclosed backtick followed by a link (the link should be parsed as Link, not Text)
5703        let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
5704        // We expect: Text("`unclosed "), Link("[link](url)")
5705        assert_eq!(elements.len(), 2);
5706        assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
5707        assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
5708    }
5709
5710    #[test]
5711    fn test_reflow_performance_long_input() {
5712        // Generate a string with many distinct unclosed backtick runs to test worst-case performance.
5713        // E.g., "` `` ` `` ` ...`"
5714        let mut text = String::new();
5715        for i in 1..400 {
5716            let backticks = "`".repeat(i);
5717            text.push_str(&backticks);
5718            text.push(' ');
5719        }
5720
5721        let start = std::time::Instant::now();
5722        let elements = parse_markdown_elements_inner(&text, false, false, None);
5723        let duration = start.elapsed();
5724
5725        // Ensure it completes in under 100ms.
5726        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5727        assert!(!elements.is_empty());
5728    }
5729
5730    #[test]
5731    fn test_reflow_performance_display_math_heavy() {
5732        // Every consumed `$$a$$` leaves the cursor directly after a `$`. The
5733        // inline-math slice-start probe must run in place at the cursor; a
5734        // suffix rescan there makes this input quadratic (~9s in a debug
5735        // build for these 4000 spans).
5736        let text = "$$a$$".repeat(4000);
5737
5738        let start = std::time::Instant::now();
5739        let elements = parse_markdown_elements_inner(&text, false, false, None);
5740        let duration = start.elapsed();
5741
5742        assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
5743        assert_eq!(elements.len(), 4000);
5744    }
5745
5746    #[test]
5747    fn inline_math_len_at_start_matches_regex_at_slice_start() {
5748        // Exhaustive parity with INLINE_MATH_REGEX over short `$`-soup
5749        // strings: the helper must equal "regex match starting at position 0"
5750        // exactly, since the regex's leading lookbehind is vacuous at a slice
5751        // start. Any drift silently changes which math spans stay atomic.
5752        let alphabet = ['$', 'a', ' '];
5753        let mut inputs: Vec<String> = vec![String::new()];
5754        let mut frontier: Vec<String> = vec![String::new()];
5755        for _ in 0..6 {
5756            let mut longer = Vec::new();
5757            for prefix in &frontier {
5758                for ch in alphabet {
5759                    let mut s = prefix.clone();
5760                    s.push(ch);
5761                    longer.push(s);
5762                }
5763            }
5764            inputs.extend(longer.iter().cloned());
5765            frontier = longer;
5766        }
5767        // Multi-byte content must count bytes, not characters.
5768        inputs.push("$αβ$x".to_string());
5769        inputs.push("$α$$".to_string());
5770
5771        for s in &inputs {
5772            let expected = INLINE_MATH_REGEX
5773                .find(s)
5774                .ok()
5775                .flatten()
5776                .filter(|m| m.start() == 0)
5777                .map(|m| m.end());
5778            assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
5779        }
5780    }
5781
5782    #[test]
5783    fn inline_math_probe_after_dollar_matches_uncached_parse() {
5784        // Expected element lists verified against the uncached parser (the
5785        // parent of the match-cache commit): when a consumed span leaves the
5786        // cursor directly after a `$`, the at-cursor probe must reproduce
5787        // exactly what rescanning the suffix used to find - both the hits
5788        // (the lookbehind is vacuous at the cursor) and the misses.
5789        let cases = [
5790            ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
5791            (
5792                "$$a$$$b$ $$a$$$b$",
5793                r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
5794            ),
5795            // Probe hit whose content is only whitespace.
5796            (
5797                "$$a$$$ x $y z$",
5798                r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
5799            ),
5800            // Probe miss: `$$` after the cursor is not inline math.
5801            ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
5802            ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
5803            // Probe miss: the trailing lookahead rejects `$c$$`.
5804            (
5805                "$a$$b$$c$$d$ tail",
5806                r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
5807            ),
5808        ];
5809        for (input, expected) in cases {
5810            let elements = parse_markdown_elements_inner(input, false, false, None);
5811            assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
5812        }
5813    }
5814
5815    #[test]
5816    fn test_atomic_spans() {
5817        // --- Emphasis Spans ---
5818        let text_emphasis = "hello **word1 word2**";
5819
5820        let options_disabled = ReflowOptions {
5821            line_length: 18,
5822            atomic_spans: true,
5823            ..Default::default()
5824        };
5825        let lines_disabled = reflow_line(text_emphasis, &options_disabled);
5826        assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
5827
5828        let options_enabled = ReflowOptions {
5829            line_length: 18,
5830            atomic_spans: false,
5831            ..Default::default()
5832        };
5833        let lines_enabled = reflow_line(text_emphasis, &options_enabled);
5834        assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
5835
5836        // --- Code Spans ---
5837        let text_code = "hello `word1 word2`";
5838
5839        let lines_code_disabled = reflow_line(text_code, &options_disabled);
5840        assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
5841
5842        let lines_code_enabled = reflow_line(text_code, &options_enabled);
5843        assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
5844
5845        // Test multiple backticks with space padding
5846        let text_code_padding = "hello `` `word1` `word2` ``";
5847        let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
5848        assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
5849
5850        // Test atomic span wrapping with attached punctuation (maintainer feedback)
5851        let text_attached = "**one two**,"; // length 12, bold span is 11
5852
5853        // With limit 11, the bold span (11) fits, so it should NOT be split even though the total (12) exceeds 11.
5854        let options_11 = ReflowOptions {
5855            line_length: 11,
5856            atomic_spans: true,
5857            ..Default::default()
5858        };
5859        assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
5860
5861        // With limit 10, the bold span (11) exceeds 10, so it is allowed to be split.
5862        let options_10 = ReflowOptions {
5863            line_length: 10,
5864            atomic_spans: true,
5865            ..Default::default()
5866        };
5867        assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
5868    }
5869
5870    #[test]
5871    fn test_emphasis_containing_markers_is_not_split() {
5872        let options = ReflowOptions {
5873            line_length: 5,
5874            atomic_spans: false,
5875            ..Default::default()
5876        };
5877        // Emphasis containing internal markers (e.g. escaped asterisks) should not be split to avoid formatting corruption
5878        let lines = reflow_line(r#"*foo \*bar*"#, &options);
5879        assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
5880    }
5881
5882    /// The parsed shape of a markdown fragment, normalized the way wrapping is
5883    /// allowed to change it and no further: block/inline structure and
5884    /// code-span contents are compared exactly, while prose whitespace is
5885    /// collapsed, because a wrap only ever swaps a space for a newline.
5886    fn semantic_shape(markdown: &str) -> String {
5887        let mut options = Options::empty();
5888        options.insert(Options::ENABLE_STRIKETHROUGH);
5889        let mut out = String::new();
5890        let push_prose = |out: &mut String, text: &str| {
5891            for c in text.chars() {
5892                if c.is_whitespace() {
5893                    if !out.ends_with(char::is_whitespace) {
5894                        out.push(' ');
5895                    }
5896                } else {
5897                    out.push(c);
5898                }
5899            }
5900        };
5901        for event in Parser::new_ext(markdown, options) {
5902            match event {
5903                Event::Text(text) => push_prose(&mut out, &text),
5904                Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
5905                // Interior whitespace in a code span is literal: compare verbatim.
5906                Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
5907                Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
5908                Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
5909                other => out.push_str(&format!("{other:?}")),
5910            }
5911        }
5912        out.trim().to_string()
5913    }
5914
5915    #[test]
5916    fn test_wrapping_a_span_never_changes_what_it_parses_to() {
5917        // Breaking a span is only safe if the document still parses the same.
5918        // Cover both settings and several budgets so the break lands in a
5919        // different place in each run.
5920        let corpus = [
5921            "_This is a very, very, very, very, very long line with some `code` inside._",
5922            "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa lambda_",
5923            "**strong text with `code` and more words than fit on one single line**",
5924            "~~struck text with `code` and more words than fit on one single line~~",
5925            "_emphasis with **nested strong that is quite long** and trailing words_",
5926            // Doubly nested spans: the whole content of the outer span is one
5927            // nested span, so there is no prose outside it to break at.
5928            "***A doubly nested bold italic span with more words than fit on a line***",
5929            "___Another doubly nested span with more words than fit on a single line___",
5930            "**_mixed strong then emphasis with more words than fit on a single line_**",
5931            "*__mixed emphasis then strong with more words than fit on a single line__*",
5932            "**~~strong strikethrough with more words than fit on a single line here~~**",
5933            // A marker that belongs to no well-formed span. Breaking at these
5934            // spaces would start a line with `* `, making it a list item.
5935            "**a * b with a stray marker and plenty more words to pass the budget**",
5936            "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
5937            "text before _a long emphasis with `code` inside of it here_ and after",
5938            "(_a parenthesized long emphasis with `code` inside of it right here_)",
5939            r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
5940            "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
5941            // A link nested in the span: its destination and title are not prose
5942            // and cannot absorb a line break.
5943            "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
5944            "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
5945            r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
5946            "_A [link with a long label](https://example.com/path) and `code` here._",
5947            "_An image ![alt text here](<a b c d e f g h>) plus `code` and more text_",
5948        ];
5949        for text in corpus {
5950            let expected = semantic_shape(text);
5951            for line_length in [20, 30, 40, 80] {
5952                for atomic_spans in [true, false] {
5953                    let options = ReflowOptions {
5954                        line_length,
5955                        atomic_spans,
5956                        ..Default::default()
5957                    };
5958                    let wrapped = reflow_line(text, &options).join("\n");
5959                    assert_eq!(
5960                        semantic_shape(&wrapped),
5961                        expected,
5962                        "reflow changed the parse of {text:?} at line_length={line_length} \
5963                         atomic_spans={atomic_spans}\n  wrapped: {wrapped:?}"
5964                    );
5965                }
5966            }
5967        }
5968    }
5969
5970    #[test]
5971    fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
5972        // Wiki links, Hugo shortcodes and math are atomic elements at the top
5973        // level but are invisible to the CommonMark parser, so `semantic_shape`
5974        // cannot catch a break inside one. Assert directly that they survive.
5975        let cases = [
5976            (
5977                "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
5978                "[[a wiki link]]",
5979            ),
5980            (
5981                "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
5982                "{{< foo bar >}}",
5983            ),
5984            ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
5985            ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
5986        ];
5987        for (text, construct) in cases {
5988            for line_length in [12, 20, 30] {
5989                for atomic_spans in [true, false] {
5990                    let options = ReflowOptions {
5991                        line_length,
5992                        atomic_spans,
5993                        ..Default::default()
5994                    };
5995                    let wrapped = reflow_line(text, &options).join("\n");
5996                    assert!(
5997                        wrapped.contains(construct),
5998                        "{construct} was broken at line_length={line_length} \
5999                         atomic_spans={atomic_spans}: {wrapped:?}"
6000                    );
6001                }
6002            }
6003        }
6004    }
6005
6006    #[test]
6007    fn test_overlong_emphasis_with_nested_code_span_wraps() {
6008        // An emphasis span longer than the whole line budget must still wrap,
6009        // even when it contains a nested code span: keeping it atomic would
6010        // leave a line that can never fit.
6011        let options = ReflowOptions {
6012            line_length: 80,
6013            atomic_spans: true,
6014            ..Default::default()
6015        };
6016        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
6017        let lines = reflow_line(text, &options);
6018        assert_eq!(
6019            lines,
6020            vec![
6021                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6022                "characters with some `code` inside._",
6023            ]
6024        );
6025    }
6026
6027    #[test]
6028    fn test_overlong_emphasis_with_nested_strong_wraps() {
6029        // Same for a nested strong span. The nested span itself stays whole.
6030        let options = ReflowOptions {
6031            line_length: 80,
6032            atomic_spans: true,
6033            ..Default::default()
6034        };
6035        let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
6036        let lines = reflow_line(text, &options);
6037        assert_eq!(
6038            lines,
6039            vec![
6040                "_This is a very, very, very, very, very, very, very long line that exceeds 80",
6041                "characters with some **bold** inside._",
6042            ]
6043        );
6044    }
6045
6046    #[test]
6047    fn test_overlong_doubly_nested_span_wraps() {
6048        // The whole content of the outer span is a single nested emphasis span.
6049        // Holding a nested span whole regardless of length left no break point
6050        // anywhere inside, so the line could never be wrapped and MD013 reported
6051        // a violation its own fixer refused to touch.
6052        let options = ReflowOptions {
6053            line_length: 80,
6054            atomic_spans: true,
6055            ..Default::default()
6056        };
6057        let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
6058        for (open, close) in [
6059            ("***", "***"),
6060            ("___", "___"),
6061            ("**_", "_**"),
6062            ("*__", "__*"),
6063            ("**~~", "~~**"),
6064        ] {
6065            let text = format!("{open}{body}{close}");
6066            assert!(text.len() > options.line_length, "case must start over budget");
6067            let lines = reflow_line(&text, &options);
6068            assert!(
6069                lines.len() > 1,
6070                "{open}...{close} should wrap but stayed on one line: {lines:?}"
6071            );
6072            assert!(
6073                lines.iter().all(|line| line.len() <= options.line_length),
6074                "{open}...{close} left a line over the budget: {lines:?}"
6075            );
6076            assert_eq!(
6077                lines.join(" "),
6078                text,
6079                "{open}...{close} wrapping must only replace a space with a newline"
6080            );
6081        }
6082    }
6083
6084    #[test]
6085    fn test_overlong_span_with_stray_marker_stays_whole() {
6086        // A `*` that belongs to no well-formed span means the content is not
6087        // fully modelled. Breaking at these spaces would put `* ` at the start
6088        // of a line, turning literal text into a list item.
6089        let options = ReflowOptions {
6090            line_length: 40,
6091            atomic_spans: true,
6092            ..Default::default()
6093        };
6094        let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
6095        let lines = reflow_line(text, &options);
6096        assert_eq!(lines, vec![text], "stray marker must keep the span whole");
6097    }
6098
6099    #[test]
6100    fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
6101        // A reference-style link only looks like a link once the document's
6102        // definitions are in scope, so the span's own parse sees plain text and
6103        // used to break inside the label. The top level holds these atomic, and
6104        // an inner span has to agree or `fmt` splits a link in one context and
6105        // not the other.
6106        let options = ReflowOptions {
6107            line_length: 30,
6108            atomic_spans: true,
6109            defined_references: Some(HashSet::from([
6110                "ref".to_string(),
6111                // A bare `[text]` is a link only when its own label is defined.
6112                "one two three four five six seven".to_string(),
6113            ])),
6114            ..Default::default()
6115        };
6116        for (text, link) in [
6117            (
6118                "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
6119                "[one two three four five six seven][ref]",
6120            ),
6121            (
6122                "**alpha [one two three four five six seven][ref] beta gamma delta**",
6123                "[one two three four five six seven][ref]",
6124            ),
6125            (
6126                "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
6127                "![one two three four five six seven][ref]",
6128            ),
6129            (
6130                "_**alpha [one two three four five six seven][] beta gamma delta**_",
6131                "[one two three four five six seven][]",
6132            ),
6133            (
6134                "_**alpha [one two three four five six seven] beta gamma delta**_",
6135                "[one two three four five six seven]",
6136            ),
6137        ] {
6138            let lines = reflow_line(text, &options);
6139            assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6140            assert!(
6141                lines.iter().any(|line| line.contains(link)),
6142                "{link} must stay on one line: {lines:?}"
6143            );
6144            assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6145        }
6146    }
6147
6148    #[test]
6149    fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
6150        // A bare `[text]` is only a link when its label is defined. With the
6151        // definitions in scope and no match, it is literal prose and breaks like
6152        // any other words, exactly as the top level treats it.
6153        let options = ReflowOptions {
6154            line_length: 30,
6155            atomic_spans: true,
6156            defined_references: Some(HashSet::new()),
6157            ..Default::default()
6158        };
6159        let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
6160        let lines = reflow_line(text, &options);
6161        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6162        assert!(
6163            !lines
6164                .iter()
6165                .any(|line| line.contains("[one two three four five six seven]")),
6166            "an undefined shortcut is prose and should break: {lines:?}"
6167        );
6168        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6169    }
6170
6171    #[test]
6172    fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
6173        // A MkDocs/kramdown attr list carries structural interior whitespace, so
6174        // splitting it rewrites the attributes. The top level holds it whole; an
6175        // inner span has to agree. Only when the flavor is enabled.
6176        let attr = "{.highlight key=\"a b c\"}";
6177        let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
6178        let options = ReflowOptions {
6179            line_length: 20,
6180            atomic_spans: true,
6181            attr_lists: true,
6182            ..Default::default()
6183        };
6184        let lines = reflow_line(&text, &options);
6185        assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
6186        assert!(
6187            lines.iter().any(|line| line.contains(attr)),
6188            "attr list must stay on one line: {lines:?}"
6189        );
6190        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6191
6192        // With the flavor off, the same braces are literal prose and break like
6193        // any other words, exactly as the top level treats them.
6194        let plain = ReflowOptions {
6195            attr_lists: false,
6196            ..options
6197        };
6198        let lines = reflow_line(&text, &plain);
6199        assert!(
6200            !lines.iter().any(|line| line.contains(attr)),
6201            "without the flavor the braces are prose and should break: {lines:?}"
6202        );
6203        assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
6204    }
6205
6206    #[test]
6207    fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
6208        // Interior whitespace in a code span is literal, so a break inside one
6209        // would rewrite the code. The nested span is a single unbreakable unit
6210        // and its interior survives byte-for-byte.
6211        let options = ReflowOptions {
6212            line_length: 30,
6213            atomic_spans: true,
6214            ..Default::default()
6215        };
6216        let text = "_alpha beta gamma delta epsilon `a  b` zeta eta theta iota kappa_";
6217        let lines = reflow_line(text, &options);
6218        assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
6219        assert!(
6220            lines.iter().any(|line| line.contains("`a  b`")),
6221            "nested code span must stay whole with its interior spaces: {lines:?}"
6222        );
6223        for line in &lines {
6224            assert_eq!(
6225                line.matches('`').count() % 2,
6226                0,
6227                "no line may contain half a code span: {line:?}"
6228            );
6229        }
6230    }
6231
6232    #[test]
6233    fn test_definition_list_marker_does_not_start_line() {
6234        let options = ReflowOptions {
6235            line_length: 20,
6236            ..Default::default()
6237        };
6238        // Wrap should not start a line with ": "
6239        let lines = reflow_line("This is a term and : definition here.", &options);
6240        for line in &lines {
6241            assert!(
6242                !line.trim_start().starts_with(": "),
6243                "Wrapped line should not start with definition marker: {line}"
6244            );
6245        }
6246    }
6247
6248    #[test]
6249    fn test_div_marker_does_not_start_line() {
6250        let options = ReflowOptions {
6251            line_length: 20,
6252            ..Default::default()
6253        };
6254        // Wrap should not start a line with ":::"
6255        let lines = reflow_line("This is some text with ::: class marker.", &options);
6256        for line in &lines {
6257            assert!(
6258                !line.trim_start().starts_with(":::"),
6259                "Wrapped line should not start with div marker: {line}"
6260            );
6261        }
6262    }
6263}