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