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