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