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