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