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