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/// Options for reflowing text
44#[derive(Clone)]
45pub struct ReflowOptions {
46    /// Target line length
47    pub line_length: usize,
48    /// Whether to break on sentence boundaries when possible
49    pub break_on_sentences: bool,
50    /// Whether to preserve existing line breaks in paragraphs
51    pub preserve_breaks: bool,
52    /// Whether to enforce one sentence per line
53    pub sentence_per_line: bool,
54    /// Whether to use semantic line breaks (cascading split strategy)
55    pub semantic_line_breaks: bool,
56    /// Custom abbreviations for sentence detection
57    /// Periods are optional - both "Dr" and "Dr." work the same
58    /// Custom abbreviations are always added to the built-in defaults
59    pub abbreviations: Option<Vec<String>>,
60    /// How to measure string length for line-length comparisons
61    pub length_mode: ReflowLengthMode,
62    /// Whether to treat {#id .class key="value"} as atomic (unsplittable) elements.
63    /// Enabled for MkDocs and Kramdown flavors.
64    pub attr_lists: bool,
65    /// Whether to treat MyST inline roles (`` {role}`content` ``) as atomic
66    /// (unsplittable) elements. Enabled for the MyST flavor so the colon inside
67    /// `{domain:role}` is never used as a clause-break point.
68    pub myst_roles: bool,
69    /// Whether to require uppercase after periods for sentence detection.
70    /// When true (default), only "word. Capital" is a sentence boundary.
71    /// When false, "word. lowercase" is also treated as a sentence boundary.
72    /// Does not affect ! and ? which are always treated as sentence boundaries.
73    pub require_sentence_capital: bool,
74    /// Cap list continuation indent to this value when set.
75    /// Used by mkdocs flavor where continuation is always 4 spaces
76    /// regardless of checkbox markers.
77    pub max_list_continuation_indent: Option<usize>,
78    /// Defined reference labels for the surrounding document, used to decide
79    /// whether a bare shortcut reference (`[text]`) is a real link (kept atomic
80    /// during reflow) or literal bracketed prose (wrapped like normal text).
81    ///
82    /// `None` means no reference information is available: every shortcut is
83    /// treated as atomic. This is the safe default - it never splits a real
84    /// link, at the cost of also not wrapping literal bracketed prose.
85    ///
86    /// `Some(set)` enables definition-aware behavior: a shortcut is atomic only
87    /// when its normalized label (see [`normalize_reference_label`]) is in the
88    /// set. Full and collapsed reference links and reference images are always
89    /// atomic regardless, because their `][ref]` / `[]` syntax is an explicit
90    /// link signal that does not depend on a definition being in scope.
91    pub defined_references: Option<HashSet<String>>,
92}
93
94impl Default for ReflowOptions {
95    fn default() -> Self {
96        Self {
97            line_length: 80,
98            break_on_sentences: true,
99            preserve_breaks: false,
100            sentence_per_line: false,
101            semantic_line_breaks: false,
102            abbreviations: None,
103            length_mode: ReflowLengthMode::default(),
104            attr_lists: false,
105            myst_roles: false,
106            require_sentence_capital: true,
107            max_list_continuation_indent: None,
108            defined_references: None,
109        }
110    }
111}
112
113/// Normalize a reference label for definition matching: collapse internal
114/// whitespace runs to a single space, trim, and lowercase (CommonMark-style
115/// label matching). Both the defined labels and the shortcut references checked
116/// against them are run through this function, so matching is case- and
117/// whitespace-insensitive. Biasing toward matching keeps a real shortcut link
118/// atomic even when its use and definition differ only in case or whitespace.
119pub fn normalize_reference_label(label: &str) -> String {
120    label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
121}
122
123/// Build a boolean mask indicating which character positions are inside inline code spans.
124/// Handles single, double, and triple backtick delimiters.
125fn compute_inline_code_mask(text: &str) -> Vec<bool> {
126    let code_spans = extract_code_spans(text);
127    let chars: Vec<char> = text.chars().collect();
128    let mut mask = vec![false; chars.len()];
129    let mut span_it = code_spans.iter().peekable();
130    let mut byte_idx = 0;
131    // Map character indices to byte-offset based code spans in a single pass.
132    // Since code spans are sorted by start offset, we advance the span iterator
133    // as our character byte index passes the end of the current span.
134    for (char_idx, ch) in chars.iter().enumerate() {
135        let next_byte_idx = byte_idx + ch.len_utf8();
136        while let Some(span) = span_it.peek() {
137            if span.end <= byte_idx {
138                span_it.next();
139            } else {
140                break;
141            }
142        }
143        if let Some(span) = span_it.peek()
144            && byte_idx >= span.start
145            && byte_idx < span.end
146        {
147            mask[char_idx] = true;
148        }
149        byte_idx = next_byte_idx;
150    }
151    mask
152}
153
154/// Detect if a character position is a sentence boundary
155/// Based on the approach from github.com/JoshuaKGoldberg/sentences-per-line
156/// Supports both ASCII punctuation (. ! ?) and CJK punctuation (。 ! ?)
157fn is_sentence_boundary(
158    text: &str,
159    chars: &[char],
160    pos: usize,
161    abbreviations: &HashSet<String>,
162    require_sentence_capital: bool,
163) -> bool {
164    if pos + 1 >= chars.len() {
165        return false;
166    }
167
168    let c = chars[pos];
169    let next_char = chars[pos + 1];
170
171    // Check for CJK sentence-ending punctuation (。, !, ?)
172    // CJK punctuation doesn't require space or uppercase after it
173    if is_cjk_sentence_ending(c) {
174        // Skip any trailing emphasis/strikethrough markers
175        let mut after_punct_pos = pos + 1;
176        while after_punct_pos < chars.len()
177            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
178        {
179            after_punct_pos += 1;
180        }
181
182        // Skip whitespace
183        while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
184            after_punct_pos += 1;
185        }
186
187        // Check if we have more content (any non-whitespace)
188        if after_punct_pos >= chars.len() {
189            return false;
190        }
191
192        // Skip leading emphasis/strikethrough markers
193        while after_punct_pos < chars.len()
194            && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
195        {
196            after_punct_pos += 1;
197        }
198
199        if after_punct_pos >= chars.len() {
200            return false;
201        }
202
203        // For CJK, we accept any character as the start of the next sentence
204        // (no uppercase requirement, since CJK doesn't have case)
205        return true;
206    }
207
208    // Check for ASCII sentence-ending punctuation
209    if c != '.' && c != '!' && c != '?' {
210        return false;
211    }
212
213    // Must be followed by space, closing quote, or emphasis/strikethrough marker followed by space
214    let (_space_pos, after_space_pos) = if next_char == ' ' {
215        // Normal case: punctuation followed by space
216        (pos + 1, pos + 2)
217    } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
218        // Sentence ends with quote - check what follows the quote
219        if chars[pos + 2] == ' ' {
220            // Just quote followed by space: 'sentence." '
221            (pos + 2, pos + 3)
222        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_') && pos + 3 < chars.len() && chars[pos + 3] == ' ' {
223            // Quote followed by emphasis: 'sentence."* '
224            (pos + 3, pos + 4)
225        } else if (chars[pos + 2] == '*' || chars[pos + 2] == '_')
226            && pos + 4 < chars.len()
227            && chars[pos + 3] == chars[pos + 2]
228            && chars[pos + 4] == ' '
229        {
230            // Quote followed by bold: 'sentence."** '
231            (pos + 4, pos + 5)
232        } else {
233            return false;
234        }
235    } else if (next_char == '*' || next_char == '_') && pos + 2 < chars.len() && chars[pos + 2] == ' ' {
236        // Sentence ends with emphasis: "sentence.* " or "sentence._ "
237        (pos + 2, pos + 3)
238    } else if (next_char == '*' || next_char == '_')
239        && pos + 3 < chars.len()
240        && chars[pos + 2] == next_char
241        && chars[pos + 3] == ' '
242    {
243        // Sentence ends with bold: "sentence.** " or "sentence.__ "
244        (pos + 3, pos + 4)
245    } else if next_char == '~' && pos + 3 < chars.len() && chars[pos + 2] == '~' && chars[pos + 3] == ' ' {
246        // Sentence ends with strikethrough: "sentence.~~ "
247        (pos + 3, pos + 4)
248    } else {
249        return false;
250    };
251
252    // Skip all whitespace after the space to find the start of the next sentence
253    let mut next_char_pos = after_space_pos;
254    while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
255        next_char_pos += 1;
256    }
257
258    // Check if we reached the end of the string
259    if next_char_pos >= chars.len() {
260        return false;
261    }
262
263    // Skip leading emphasis/strikethrough markers and opening quotes to find the actual first letter
264    let mut first_letter_pos = next_char_pos;
265    while first_letter_pos < chars.len()
266        && (chars[first_letter_pos] == '*'
267            || chars[first_letter_pos] == '_'
268            || chars[first_letter_pos] == '~'
269            || is_opening_quote(chars[first_letter_pos]))
270    {
271        first_letter_pos += 1;
272    }
273
274    // Check if we reached the end after skipping emphasis
275    if first_letter_pos >= chars.len() {
276        return false;
277    }
278
279    let first_char = chars[first_letter_pos];
280
281    // For ! and ?, sentence boundaries are unambiguous — no uppercase requirement
282    if c == '!' || c == '?' {
283        return true;
284    }
285
286    // Period-specific checks: periods are ambiguous (abbreviations, decimals, initials)
287    // so we apply additional guards before accepting a sentence boundary.
288
289    if pos > 0 {
290        // Check for common abbreviations
291        let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum();
292        if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) {
293            return false;
294        }
295
296        // Check for decimal numbers (e.g., "3.14 is pi")
297        if chars[pos - 1].is_numeric() && first_char.is_ascii_digit() {
298            return false;
299        }
300
301        // Check for single-letter initials (e.g., "J. K. Rowling")
302        // A single uppercase letter before the period preceded by whitespace or start
303        // is likely an initial, not a sentence ending.
304        if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
305            return false;
306        }
307    }
308
309    // In strict mode, require uppercase or CJK to start the next sentence after a period.
310    // In relaxed mode, accept any alphanumeric character.
311    if require_sentence_capital && !first_char.is_uppercase() && !is_cjk_char(first_char) {
312        return false;
313    }
314
315    true
316}
317
318/// Split text into sentences
319pub fn split_into_sentences(text: &str) -> Vec<String> {
320    split_into_sentences_custom(text, &None)
321}
322
323/// Split text into sentences with custom abbreviations
324pub fn split_into_sentences_custom(text: &str, custom_abbreviations: &Option<Vec<String>>) -> Vec<String> {
325    let abbreviations = get_abbreviations(custom_abbreviations);
326    split_into_sentences_with_set(text, &abbreviations, true)
327}
328
329/// Internal function to split text into sentences with a pre-computed abbreviations set
330/// Use this when calling multiple times in a loop to avoid repeatedly computing the set
331fn split_into_sentences_with_set(
332    text: &str,
333    abbreviations: &HashSet<String>,
334    require_sentence_capital: bool,
335) -> Vec<String> {
336    // Pre-compute which character positions are inside inline code spans
337    let in_code = compute_inline_code_mask(text);
338    // Collect chars once and share the slice with is_sentence_boundary, which
339    // would otherwise re-collect the whole text on every position it checks.
340    let char_vec: Vec<char> = text.chars().collect();
341
342    let mut sentences = Vec::new();
343    let mut current_sentence = String::new();
344    let mut chars = text.chars().peekable();
345    let mut pos = 0;
346
347    while let Some(c) = chars.next() {
348        current_sentence.push(c);
349
350        if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) {
351            // Consume any trailing emphasis/strikethrough markers and quotes (they belong to the current sentence)
352            while let Some(&next) = chars.peek() {
353                if next == '*' || next == '_' || next == '~' || is_closing_quote(next) {
354                    current_sentence.push(chars.next().unwrap());
355                    pos += 1;
356                } else {
357                    break;
358                }
359            }
360
361            // Consume the space after the sentence
362            if chars.peek() == Some(&' ') {
363                chars.next();
364                pos += 1;
365            }
366
367            sentences.push(current_sentence.trim().to_string());
368            current_sentence.clear();
369        }
370
371        pos += 1;
372    }
373
374    // Add any remaining text as the last sentence
375    if !current_sentence.trim().is_empty() {
376        sentences.push(current_sentence.trim().to_string());
377    }
378    sentences
379}
380
381/// Check if a line is a horizontal rule (---, ___, ***)
382fn is_horizontal_rule(line: &str) -> bool {
383    if line.len() < 3 {
384        return false;
385    }
386
387    // Line must consist only of a single marker char (-, _, or *) plus spaces,
388    // with at least 3 markers. Scan chars directly to avoid allocating a Vec.
389    let mut chars = line.chars();
390    let Some(first_char) = chars.next() else {
391        return false;
392    };
393    if first_char != '-' && first_char != '_' && first_char != '*' {
394        return false;
395    }
396
397    let mut non_space_count = 1usize; // first_char is a marker
398    for c in chars {
399        if c == ' ' {
400            continue;
401        }
402        if c != first_char {
403            return false;
404        }
405        non_space_count += 1;
406    }
407    non_space_count >= 3
408}
409
410/// Check if a line is a numbered list item (e.g., "1. ", "10. ")
411fn is_numbered_list_item(line: &str) -> bool {
412    let mut chars = line.chars();
413
414    // Must start with a digit
415    if !chars.next().is_some_and(char::is_numeric) {
416        return false;
417    }
418
419    // Can have more digits
420    while let Some(c) = chars.next() {
421        if c == '.' {
422            // After period, must have a space (consistent with list marker extraction)
423            // "2019." alone is NOT treated as a list item to avoid false positives
424            return chars.next() == Some(' ');
425        }
426        if !c.is_numeric() {
427            return false;
428        }
429    }
430
431    false
432}
433
434/// Check if a trimmed line is an unordered list item (-, *, + followed by space)
435fn is_unordered_list_marker(s: &str) -> bool {
436    matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
437        && !is_horizontal_rule(s)
438        && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
439}
440
441/// Shared structural checks for block boundary detection.
442/// Checks elements that only depend on the trimmed line content.
443fn is_block_boundary_core(trimmed: &str) -> bool {
444    trimmed.is_empty()
445        || trimmed.starts_with('#')
446        || trimmed.starts_with("```")
447        || trimmed.starts_with("~~~")
448        || trimmed.starts_with('>')
449        || (trimmed.starts_with('[') && trimmed.contains("]:"))
450        || is_horizontal_rule(trimmed)
451        || is_unordered_list_marker(trimmed)
452        || is_numbered_list_item(trimmed)
453        || is_definition_list_item(trimmed)
454        || trimmed.starts_with(":::")
455}
456
457/// Check if a trimmed line starts a new structural block element.
458/// Used for paragraph boundary detection in `reflow_markdown()`.
459fn is_block_boundary(trimmed: &str) -> bool {
460    is_block_boundary_core(trimmed) || trimmed.starts_with('|')
461}
462
463/// Check if a line starts a new structural block for paragraph boundary detection
464/// in `reflow_paragraph_at_line()`. Extends the core checks with indented code blocks
465/// (≥4 spaces) and table row detection via `is_potential_table_row`.
466fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
467    is_block_boundary_core(trimmed)
468        || calculate_indentation_width_default(line) >= 4
469        || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
470}
471
472/// Check if a line ends with a hard break (either two spaces or backslash)
473///
474/// CommonMark supports two formats for hard line breaks:
475/// 1. Two or more trailing spaces
476/// 2. A backslash at the end of the line
477fn has_hard_break(line: &str) -> bool {
478    let line = line.strip_suffix('\r').unwrap_or(line);
479    line.ends_with("  ") || line.ends_with('\\')
480}
481
482/// Check if text ends with sentence-terminating punctuation (. ! ?)
483fn ends_with_sentence_punct(text: &str) -> bool {
484    text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
485}
486
487/// Trim trailing whitespace while preserving hard breaks (two trailing spaces or backslash)
488///
489/// Hard breaks in Markdown can be indicated by:
490/// 1. Two trailing spaces before a newline (traditional)
491/// 2. A backslash at the end of the line (mdformat style)
492fn trim_preserving_hard_break(s: &str) -> String {
493    // Strip trailing \r from CRLF line endings first to handle Windows files
494    let s = s.strip_suffix('\r').unwrap_or(s);
495
496    // Check for backslash hard break (mdformat style)
497    if s.ends_with('\\') {
498        // Preserve the backslash exactly as-is
499        return s.to_string();
500    }
501
502    // Check if there are at least 2 trailing spaces (traditional hard break)
503    if s.ends_with("  ") {
504        // Find the position where non-space content ends
505        let content_end = s.trim_end().len();
506        if content_end == 0 {
507            // String is all whitespace
508            return String::new();
509        }
510        // Preserve exactly 2 trailing spaces for hard break
511        format!("{}  ", &s[..content_end])
512    } else {
513        // No hard break, just trim all trailing whitespace
514        s.trim_end().to_string()
515    }
516}
517
518/// Parse markdown elements using the appropriate parser based on options.
519fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
520    parse_markdown_elements_inner(
521        text,
522        options.attr_lists,
523        options.myst_roles,
524        options.defined_references.as_ref(),
525    )
526}
527
528pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
529    // For sentence-per-line mode, always process regardless of length
530    if options.sentence_per_line {
531        let elements = parse_elements(line, options);
532        return reflow_elements_sentence_per_line(&elements, &options.abbreviations, options.require_sentence_capital);
533    }
534
535    // For semantic line breaks mode, use cascading split strategy
536    if options.semantic_line_breaks {
537        let elements = parse_elements(line, options);
538        return reflow_elements_semantic(&elements, options);
539    }
540
541    // Quick check: if line is already short enough or no wrapping requested, return as-is
542    // line_length = 0 means no wrapping (unlimited line length)
543    if options.line_length == 0 || display_len(line, options.length_mode) <= options.line_length {
544        return vec![line.to_string()];
545    }
546
547    // Parse the markdown to identify elements
548    let elements = parse_elements(line, options);
549
550    // Reflow the elements into lines
551    reflow_elements(&elements, options)
552}
553
554/// Represents a piece of content in the markdown
555#[derive(Debug, Clone)]
556enum Element {
557    /// Plain text that can be wrapped
558    Text(String),
559    /// A complete markdown inline link [text](url)
560    Link(String),
561    /// A complete markdown reference link [text][ref]
562    ReferenceLink(String),
563    /// A complete markdown empty reference link [text][]
564    EmptyReferenceLink(String),
565    /// A complete markdown shortcut reference link [ref]
566    ShortcutReference(String),
567    /// A complete markdown inline image ![alt](url)
568    InlineImage(String),
569    /// A complete markdown reference image ![alt][ref]
570    ReferenceImage(String),
571    /// A complete markdown empty reference image ![alt][]
572    EmptyReferenceImage(String),
573    /// A clickable image badge
574    LinkedImage(String),
575    /// Footnote reference [^note]
576    FootnoteReference(String),
577    /// Strikethrough text ~~text~~ or ~text~ (GFM allows one or two tildes)
578    Strikethrough {
579        content: String,
580        /// True if the original used a double-tilde (~~) marker, false for a single tilde (~)
581        double: bool,
582    },
583    /// Wiki-style link [[wiki]] or [[wiki|text]]
584    WikiLink(String),
585    /// Inline math $math$
586    InlineMath(String),
587    /// Display math $$math$$
588    DisplayMath(String),
589    /// Emoji shortcode :emoji:
590    EmojiShortcode(String),
591    /// Autolink <https://...> or <mailto:...> or <user@domain.com>
592    Autolink(String),
593    /// HTML tag <tag> or </tag> or <tag/>
594    HtmlTag(String),
595    /// HTML entity &nbsp; or &#123;
596    HtmlEntity(String),
597    /// Hugo/Go template shortcode {{< ... >}} or {{% ... %}}
598    HugoShortcode(String),
599    /// MkDocs/kramdown attribute list {#id .class key="value"}
600    AttrList(String),
601    /// MyST inline role `` {role}`content` `` (or `` {domain:role}`content` ``).
602    /// Stored as the raw matched text and rendered verbatim so it round-trips
603    /// exactly; treated as atomic so it is never split mid-role.
604    MystRole(String),
605    /// Inline code `code`
606    Code(String),
607    /// Bold text **text** or __text__
608    Bold {
609        content: String,
610        /// True if underscore markers (__), false for asterisks (**)
611        underscore: bool,
612    },
613    /// Italic text *text* or _text_
614    Italic {
615        content: String,
616        /// True if underscore marker (_), false for asterisk (*)
617        underscore: bool,
618    },
619}
620
621impl std::fmt::Display for Element {
622    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623        match self {
624            Element::Text(s) => write!(f, "{s}"),
625            Element::Link(s) => write!(f, "{s}"),
626            Element::ReferenceLink(s) => write!(f, "{s}"),
627            Element::EmptyReferenceLink(s) => write!(f, "{s}"),
628            Element::ShortcutReference(s) => write!(f, "{s}"),
629            Element::InlineImage(s) => write!(f, "{s}"),
630            Element::ReferenceImage(s) => write!(f, "{s}"),
631            Element::EmptyReferenceImage(s) => write!(f, "{s}"),
632            Element::LinkedImage(s) => write!(f, "{s}"),
633            Element::FootnoteReference(s) => write!(f, "{s}"),
634            Element::Strikethrough { content, double } => {
635                let marker = if *double { "~~" } else { "~" };
636                write!(f, "{marker}{content}{marker}")
637            }
638            Element::WikiLink(s) => write!(f, "[[{s}]]"),
639            Element::InlineMath(s) => write!(f, "${s}$"),
640            Element::DisplayMath(s) => write!(f, "$${s}$$"),
641            Element::EmojiShortcode(s) => write!(f, ":{s}:"),
642            Element::Autolink(s) => write!(f, "{s}"),
643            Element::HtmlTag(s) => write!(f, "{s}"),
644            Element::HtmlEntity(s) => write!(f, "{s}"),
645            Element::HugoShortcode(s) => write!(f, "{s}"),
646            Element::AttrList(s) => write!(f, "{s}"),
647            Element::MystRole(s) => write!(f, "{s}"),
648            Element::Code(s) => write!(f, "`{s}`"),
649            Element::Bold { content, underscore } => {
650                if *underscore {
651                    write!(f, "__{content}__")
652                } else {
653                    write!(f, "**{content}**")
654                }
655            }
656            Element::Italic { content, underscore } => {
657                if *underscore {
658                    write!(f, "_{content}_")
659                } else {
660                    write!(f, "*{content}*")
661                }
662            }
663        }
664    }
665}
666
667/// An emphasis or formatting span parsed by pulldown-cmark
668#[derive(Debug, Clone)]
669struct EmphasisSpan {
670    /// Byte offset where the emphasis starts (including markers)
671    start: usize,
672    /// Byte offset where the emphasis ends (after closing markers)
673    end: usize,
674    /// The content inside the emphasis markers
675    content: String,
676    /// Whether this is strong (bold) emphasis
677    is_strong: bool,
678    /// Whether this is strikethrough (~~text~~)
679    is_strikethrough: bool,
680    /// Whether the original used underscore markers (for emphasis only)
681    uses_underscore: bool,
682    /// For strikethrough spans, whether the original used a double-tilde (~~)
683    /// marker rather than a single tilde (~). Meaningless for other spans.
684    strikethrough_double: bool,
685}
686
687/// Extract emphasis and strikethrough spans from text using pulldown-cmark
688///
689/// This provides CommonMark-compliant emphasis parsing, correctly handling:
690/// - Nested emphasis like `*text **bold** more*`
691/// - Left/right flanking delimiter rules
692/// - Underscore vs asterisk markers
693/// - GFM strikethrough (~~text~~)
694///
695/// Returns spans sorted by start position.
696fn extract_emphasis_spans(text: &str) -> Vec<EmphasisSpan> {
697    let mut spans = Vec::new();
698    let mut options = Options::empty();
699    options.insert(Options::ENABLE_STRIKETHROUGH);
700
701    // Stacks to track nested formatting with their start positions
702    let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); // (start_byte, uses_underscore)
703    let mut strong_stack: Vec<(usize, bool)> = Vec::new();
704    let mut strikethrough_stack: Vec<usize> = Vec::new();
705
706    let parser = Parser::new_ext(text, options).into_offset_iter();
707
708    for (event, range) in parser {
709        match event {
710            Event::Start(Tag::Emphasis) => {
711                // Check if this uses underscore by looking at the original text
712                let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
713                emphasis_stack.push((range.start, uses_underscore));
714            }
715            Event::End(TagEnd::Emphasis) => {
716                if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
717                    // Extract content between the markers (1 char marker on each side)
718                    let content_start = start_byte + 1;
719                    let content_end = range.end - 1;
720                    if content_end > content_start
721                        && let Some(content) = text.get(content_start..content_end)
722                    {
723                        spans.push(EmphasisSpan {
724                            start: start_byte,
725                            end: range.end,
726                            content: content.to_string(),
727                            is_strong: false,
728                            is_strikethrough: false,
729                            uses_underscore,
730                            strikethrough_double: false,
731                        });
732                    }
733                }
734            }
735            Event::Start(Tag::Strong) => {
736                // Check if this uses underscore by looking at the original text
737                let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
738                strong_stack.push((range.start, uses_underscore));
739            }
740            Event::End(TagEnd::Strong) => {
741                if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
742                    // Extract content between the markers (2 char marker on each side)
743                    let content_start = start_byte + 2;
744                    let content_end = range.end - 2;
745                    if content_end > content_start
746                        && let Some(content) = text.get(content_start..content_end)
747                    {
748                        spans.push(EmphasisSpan {
749                            start: start_byte,
750                            end: range.end,
751                            content: content.to_string(),
752                            is_strong: true,
753                            is_strikethrough: false,
754                            uses_underscore,
755                            strikethrough_double: false,
756                        });
757                    }
758                }
759            }
760            Event::Start(Tag::Strikethrough) => {
761                strikethrough_stack.push(range.start);
762            }
763            Event::End(TagEnd::Strikethrough) => {
764                if let Some(start_byte) = strikethrough_stack.pop() {
765                    // pulldown-cmark's GFM strikethrough accepts both ~~text~~ and
766                    // ~text~. Detect the actual marker width so single-tilde spans
767                    // keep their first and last content character.
768                    let double = text.get(start_byte..start_byte + 2) == Some("~~");
769                    let marker_len = if double { 2 } else { 1 };
770                    let content_start = start_byte + marker_len;
771                    let content_end = range.end - marker_len;
772                    if content_end > content_start
773                        && let Some(content) = text.get(content_start..content_end)
774                    {
775                        spans.push(EmphasisSpan {
776                            start: start_byte,
777                            end: range.end,
778                            content: content.to_string(),
779                            is_strong: false,
780                            is_strikethrough: true,
781                            uses_underscore: false,
782                            strikethrough_double: double,
783                        });
784                    }
785                }
786            }
787            _ => {}
788        }
789    }
790
791    // Sort by start position
792    spans.sort_by_key(|s| s.start);
793    spans
794}
795
796#[derive(Debug, Clone)]
797struct CodeSpan {
798    start: usize,
799    end: usize,
800}
801
802fn extract_code_spans(text: &str) -> Vec<CodeSpan> {
803    let mut spans = Vec::new();
804    let parser = Parser::new(text).into_offset_iter();
805    for (event, range) in parser {
806        if let Event::Code(_) = event {
807            spans.push(CodeSpan {
808                start: range.start,
809                end: range.end,
810            });
811        }
812    }
813    spans
814}
815
816#[derive(Debug, Clone)]
817struct LinkSpan {
818    start: usize,
819    end: usize,
820    link_type: Option<LinkType>,
821    is_image: bool,
822    is_footnote: bool,
823}
824
825fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
826    let mut spans = Vec::new();
827    let mut options = Options::empty();
828    options.insert(Options::ENABLE_FOOTNOTES);
829
830    // Reflow parses each paragraph in isolation, so the document's reference
831    // definitions are never in scope. Without a broken-link callback,
832    // pulldown-cmark would emit reference-style links (`[text][ref]`,
833    // `[text][]`, `[text]`, `![alt][ref]`) as plain text, and reflow would wrap
834    // their text mid-link. Resolving an unresolved reference to a dummy
835    // destination makes pulldown emit the full link span so reflow treats it as
836    // an atomic unit; the destination is unused because the element is rebuilt
837    // verbatim from the source bytes.
838    //
839    // Full and collapsed references and reference images carry explicit
840    // `][ref]` / `[]` syntax, so they are always resolved (atomic). A bare
841    // shortcut `[text]` is ambiguous: it is only a real link when its label is
842    // actually defined. With `Some(defined_references)` an undefined shortcut is
843    // left unresolved (returns `None`) so it reflows as literal prose; with
844    // `None` (no reference info) every shortcut stays atomic, which never splits
845    // a real link.
846    let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
847        // The callback reports the syntactic reference type (`Shortcut` for a
848        // bare `[text]`); the eventual emitted tag carries the `*Unknown`
849        // variant. Only a bare shortcut is ambiguous - full and collapsed
850        // references fall through and stay atomic.
851        let atomic = match link.link_type {
852            LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
853                Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
854                None => true,
855            },
856            _ => true,
857        };
858        atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
859    };
860    let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
861    let mut stack = Vec::new();
862
863    for (event, range) in parser {
864        match event {
865            Event::Start(Tag::Link { link_type, .. }) => {
866                stack.push((range.start, Some(link_type), false));
867            }
868            Event::Start(Tag::Image { link_type, .. }) => {
869                stack.push((range.start, Some(link_type), true));
870            }
871            Event::End(TagEnd::Link) => {
872                if let Some((start_byte, link_type, is_image)) = stack.pop()
873                    && stack.is_empty()
874                {
875                    spans.push(LinkSpan {
876                        start: start_byte,
877                        end: range.end,
878                        link_type,
879                        is_image,
880                        is_footnote: false,
881                    });
882                }
883            }
884            Event::End(TagEnd::Image) => {
885                if let Some((start_byte, link_type, is_image)) = stack.pop()
886                    && stack.is_empty()
887                {
888                    spans.push(LinkSpan {
889                        start: start_byte,
890                        end: range.end,
891                        link_type,
892                        is_image,
893                        is_footnote: false,
894                    });
895                }
896            }
897            Event::FootnoteReference(_) if stack.is_empty() => {
898                spans.push(LinkSpan {
899                    start: range.start,
900                    end: range.end,
901                    link_type: None,
902                    is_image: false,
903                    is_footnote: true,
904                });
905            }
906            _ => {}
907        }
908    }
909
910    spans.sort_by_key(|s| s.start);
911    spans
912}
913
914/// If `text` starts with a MyST inline role (`` {name}`content` `` or
915/// `` {domain:role}`content` ``), return the byte length of the whole role unit.
916///
917/// Mirrors the grammar in `lint_context::flavor_detection::detect_myst_role_ranges`:
918/// a `{`, a name starting with an ASCII letter or `_` and continuing with
919/// alphanumerics / `-` / `_` / `:` / `.`, a closing `}`, then a balanced inline
920/// code span using one or more backticks. Returns `None` when any part is missing.
921fn myst_role_len_at(text: &str) -> Option<usize> {
922    let bytes = text.as_bytes();
923    if bytes.first() != Some(&b'{') {
924        return None;
925    }
926
927    // Role name.
928    let mut j = 1;
929    match bytes.get(j) {
930        Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
931        _ => return None,
932    }
933    while let Some(&b) = bytes.get(j) {
934        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
935            j += 1;
936        } else {
937            break;
938        }
939    }
940    if bytes.get(j) != Some(&b'}') {
941        return None;
942    }
943    j += 1; // past '}'
944
945    // Must be immediately followed by an inline code span.
946    if bytes.get(j) != Some(&b'`') {
947        return None;
948    }
949    let backtick_start = j;
950    while bytes.get(j) == Some(&b'`') {
951        j += 1;
952    }
953    let backtick_count = j - backtick_start;
954
955    // Find the matching run of `backtick_count` backticks.
956    while j + backtick_count <= bytes.len() {
957        if bytes[j] == b'`' {
958            let close_count = bytes[j..].iter().take_while(|&&b| b == b'`').count();
959            if close_count == backtick_count {
960                return Some(j + close_count);
961            }
962            j += close_count;
963        } else {
964            j += 1;
965        }
966    }
967
968    None
969}
970
971/// Parse markdown elements from text preserving the raw syntax.
972///
973/// Detection order is critical:
974/// 1. Linked images [![alt](img)](link) - must be detected first as atomic units
975/// 2. Inline images ![alt](url) - before links to handle ! prefix
976/// 3. Reference images ![alt][ref] - before reference links
977/// 4. Inline links [text](url) - before reference links
978/// 5. Reference links [text][ref] - before shortcut references
979/// 6. Shortcut reference links [ref] - detected last to avoid false positives
980/// 7. Other elements (code, bold, italic, MyST roles, etc.) - processed normally
981fn parse_markdown_elements_inner(
982    text: &str,
983    attr_lists: bool,
984    myst_roles: bool,
985    defined_references: Option<&HashSet<String>>,
986) -> Vec<Element> {
987    let mut elements = Vec::new();
988    let mut remaining = text;
989
990    // Pre-extract emphasis spans and link spans using pulldown-cmark
991    let emphasis_spans = extract_emphasis_spans(text);
992    let link_spans = extract_link_spans(text, defined_references);
993
994    while !remaining.is_empty() {
995        // Calculate current byte offset in original text
996        let current_offset = text.len() - remaining.len();
997        // Find the earliest occurrence of any markdown pattern
998        // Store (start, end, pattern_name) to unify standard Regex and FancyRegex match results
999        let mut earliest_match: Option<(usize, usize, &str)> = None;
1000
1001        // Find the earliest link span
1002        let mut next_link: Option<&LinkSpan> = None;
1003        for span in &link_spans {
1004            if span.start >= current_offset {
1005                next_link = Some(span);
1006                break;
1007            }
1008        }
1009
1010        if let Some(span) = next_link {
1011            let pos_in_remaining = span.start - current_offset;
1012            if earliest_match
1013                .as_ref()
1014                .is_none_or(|(start, _, _)| pos_in_remaining < *start)
1015            {
1016                let match_end = span.end - current_offset;
1017                earliest_match = Some((pos_in_remaining, match_end, "link_span"));
1018            }
1019        }
1020
1021        // Check for wiki-style links - [[wiki]]
1022        if let Some(m) = WIKI_LINK_REGEX.find(remaining)
1023            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1024        {
1025            earliest_match = Some((m.start(), m.end(), "wiki_link"));
1026        }
1027
1028        // Check for display math first (before inline) - $$math$$
1029        if let Some(m) = DISPLAY_MATH_REGEX.find(remaining)
1030            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1031        {
1032            earliest_match = Some((m.start(), m.end(), "display_math"));
1033        }
1034
1035        // Check for inline math - $math$
1036        if let Ok(Some(m)) = INLINE_MATH_REGEX.find(remaining)
1037            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1038        {
1039            earliest_match = Some((m.start(), m.end(), "inline_math"));
1040        }
1041
1042        // Check for emoji shortcodes - :emoji:
1043        if let Some(m) = EMOJI_SHORTCODE_REGEX.find(remaining)
1044            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1045        {
1046            earliest_match = Some((m.start(), m.end(), "emoji"));
1047        }
1048
1049        // Check for HTML entities - &nbsp; etc
1050        if let Some(m) = HTML_ENTITY_REGEX.find(remaining)
1051            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1052        {
1053            earliest_match = Some((m.start(), m.end(), "html_entity"));
1054        }
1055
1056        // Check for Hugo shortcodes - {{< ... >}} or {{% ... %}}
1057        // Must be checked before other patterns to avoid false sentence breaks
1058        if let Some(m) = HUGO_SHORTCODE_REGEX.find(remaining)
1059            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1060        {
1061            earliest_match = Some((m.start(), m.end(), "hugo_shortcode"));
1062        }
1063
1064        // Check for HTML tags - <tag> </tag> <tag/>
1065        // But exclude autolinks like <https://...> or <mailto:...> or email autolinks <user@domain.com>
1066        if let Some(m) = HTML_TAG_PATTERN.find(remaining)
1067            && earliest_match.as_ref().is_none_or(|(start, _, _)| m.start() < *start)
1068        {
1069            // Check if this is an autolink (starts with protocol or mailto:)
1070            let matched_text = &remaining[m.start()..m.end()];
1071            let is_url_autolink = matched_text.starts_with("<http://")
1072                || matched_text.starts_with("<https://")
1073                || matched_text.starts_with("<mailto:")
1074                || matched_text.starts_with("<ftp://")
1075                || matched_text.starts_with("<ftps://");
1076
1077            // Check if this is an email autolink (per CommonMark spec: <local@domain.tld>)
1078            // Use centralized EMAIL_PATTERN for consistency with MD034 and other rules
1079            let is_email_autolink = {
1080                let content = matched_text.trim_start_matches('<').trim_end_matches('>');
1081                EMAIL_PATTERN.is_match(content)
1082            };
1083
1084            if is_url_autolink || is_email_autolink {
1085                // Autolinks are handled by link_span
1086            } else {
1087                earliest_match = Some((m.start(), m.end(), "html_tag"));
1088            }
1089        }
1090
1091        // Find earliest non-link special characters
1092        let mut next_special = remaining.len();
1093        let mut special_type = "";
1094        let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
1095        let mut attr_list_len: usize = 0;
1096        let mut myst_role_len: usize = 0;
1097
1098        // Check for code spans (not handled by pulldown-cmark in this context)
1099        if let Some(pos) = remaining.find('`')
1100            && pos < next_special
1101        {
1102            next_special = pos;
1103            special_type = "code";
1104        }
1105
1106        // Check for MyST inline roles - {role}`content` (e.g. {cite:p}`ref`).
1107        // Checked before the bare code-span handling so the role's trailing code
1108        // span is absorbed into the atomic role rather than split off, and before
1109        // attr lists since a role's `{` would otherwise be probed as an attr list.
1110        if myst_roles
1111            && let Some(pos) = remaining.find('{')
1112            && pos < next_special
1113            && let Some(role_len) = myst_role_len_at(&remaining[pos..])
1114        {
1115            next_special = pos;
1116            special_type = "myst_role";
1117            myst_role_len = role_len;
1118        }
1119
1120        // Check for MkDocs/kramdown attr lists - {#id .class key="value"}
1121        if attr_lists
1122            && let Some(pos) = remaining.find('{')
1123            && pos < next_special
1124            && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
1125            && m.start() == 0
1126        {
1127            next_special = pos;
1128            special_type = "attr_list";
1129            attr_list_len = m.end();
1130        }
1131
1132        // Check for emphasis using pulldown-cmark's pre-extracted spans
1133        // Find the earliest emphasis span that starts within remaining text
1134        for span in &emphasis_spans {
1135            if span.start >= current_offset && span.start < current_offset + remaining.len() {
1136                let pos_in_remaining = span.start - current_offset;
1137                if pos_in_remaining < next_special {
1138                    next_special = pos_in_remaining;
1139                    special_type = "pulldown_emphasis";
1140                    pulldown_emphasis = Some(span);
1141                }
1142                break; // Spans are sorted by start position, so first match is earliest
1143            }
1144        }
1145
1146        // Determine which pattern to process first
1147        let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
1148            pos < next_special
1149        } else {
1150            false
1151        };
1152
1153        if should_process_markdown_link {
1154            let (pos, match_end, pattern_type) = earliest_match.unwrap();
1155
1156            // Add any text before the match
1157            if pos > 0 {
1158                elements.push(Element::Text(remaining[..pos].to_string()));
1159            }
1160
1161            // Process the matched pattern
1162            match pattern_type {
1163                "link_span" => {
1164                    let span = next_link.unwrap();
1165                    let raw_text = remaining[pos..match_end].to_string();
1166                    if span.is_footnote {
1167                        elements.push(Element::FootnoteReference(raw_text));
1168                    } else if span.is_image {
1169                        match span.link_type {
1170                            Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
1171                            // `*Unknown` variants are produced when reflow's broken-link
1172                            // callback resolves a reference whose definition is out of scope.
1173                            Some(LinkType::Reference)
1174                            | Some(LinkType::ReferenceUnknown)
1175                            | Some(LinkType::Shortcut)
1176                            | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
1177                            Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1178                                elements.push(Element::EmptyReferenceImage(raw_text))
1179                            }
1180                            _ => elements.push(Element::InlineImage(raw_text)),
1181                        }
1182                    } else {
1183                        match span.link_type {
1184                            Some(LinkType::Inline) => {
1185                                if raw_text.starts_with('[') && raw_text.contains("![") {
1186                                    elements.push(Element::LinkedImage(raw_text));
1187                                } else {
1188                                    elements.push(Element::Link(raw_text));
1189                                }
1190                            }
1191                            // `*Unknown` variants are produced when reflow's broken-link
1192                            // callback resolves a reference whose definition is out of scope.
1193                            Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
1194                                elements.push(Element::ReferenceLink(raw_text))
1195                            }
1196                            Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
1197                                elements.push(Element::EmptyReferenceLink(raw_text))
1198                            }
1199                            Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
1200                                elements.push(Element::ShortcutReference(raw_text))
1201                            }
1202                            Some(LinkType::Autolink) | Some(LinkType::Email) => {
1203                                elements.push(Element::Autolink(raw_text))
1204                            }
1205                            _ => elements.push(Element::Link(raw_text)),
1206                        }
1207                    }
1208                    remaining = &remaining[match_end..];
1209                }
1210                "wiki_link" => {
1211                    if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
1212                        let content = caps.get(1).map_or("", |m| m.as_str());
1213                        elements.push(Element::WikiLink(content.to_string()));
1214                        remaining = &remaining[match_end..];
1215                    } else {
1216                        elements.push(Element::Text("[[".to_string()));
1217                        remaining = &remaining[2..];
1218                    }
1219                }
1220                "display_math" => {
1221                    if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
1222                        let math = caps.get(1).map_or("", |m| m.as_str());
1223                        elements.push(Element::DisplayMath(math.to_string()));
1224                        remaining = &remaining[match_end..];
1225                    } else {
1226                        elements.push(Element::Text("$$".to_string()));
1227                        remaining = &remaining[2..];
1228                    }
1229                }
1230                "inline_math" => {
1231                    if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
1232                        let math = caps.get(1).map_or("", |m| m.as_str());
1233                        elements.push(Element::InlineMath(math.to_string()));
1234                        remaining = &remaining[match_end..];
1235                    } else {
1236                        elements.push(Element::Text("$".to_string()));
1237                        remaining = &remaining[1..];
1238                    }
1239                }
1240                "emoji" => {
1241                    if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
1242                        let emoji = caps.get(1).map_or("", |m| m.as_str());
1243                        elements.push(Element::EmojiShortcode(emoji.to_string()));
1244                        remaining = &remaining[match_end..];
1245                    } else {
1246                        elements.push(Element::Text(":".to_string()));
1247                        remaining = &remaining[1..];
1248                    }
1249                }
1250                "html_entity" => {
1251                    // HTML entities are captured whole
1252                    elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
1253                    remaining = &remaining[match_end..];
1254                }
1255                "hugo_shortcode" => {
1256                    // Hugo shortcodes are atomic elements - preserve them exactly
1257                    elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
1258                    remaining = &remaining[match_end..];
1259                }
1260                "html_tag" => {
1261                    // HTML tags are captured whole
1262                    elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
1263                    remaining = &remaining[match_end..];
1264                }
1265                _ => {
1266                    // Unknown pattern, treat as text
1267                    elements.push(Element::Text("[".to_string()));
1268                    remaining = &remaining[1..];
1269                }
1270            }
1271        } else {
1272            // Process non-link special characters
1273
1274            // Add any text before the special character
1275            if next_special > 0 && next_special < remaining.len() {
1276                elements.push(Element::Text(remaining[..next_special].to_string()));
1277                remaining = &remaining[next_special..];
1278            }
1279
1280            // Process the special element
1281            match special_type {
1282                "code" => {
1283                    // Find end of code
1284                    if let Some(code_end) = remaining[1..].find('`') {
1285                        let code = &remaining[1..=code_end];
1286                        elements.push(Element::Code(code.to_string()));
1287                        remaining = &remaining[1 + code_end + 1..];
1288                    } else {
1289                        // No closing backtick, treat as text
1290                        elements.push(Element::Text(remaining.to_string()));
1291                        break;
1292                    }
1293                }
1294                "attr_list" => {
1295                    elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
1296                    remaining = &remaining[attr_list_len..];
1297                }
1298                "myst_role" => {
1299                    elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
1300                    remaining = &remaining[myst_role_len..];
1301                }
1302                "pulldown_emphasis" => {
1303                    // Use pre-extracted emphasis/strikethrough span from pulldown-cmark
1304                    if let Some(span) = pulldown_emphasis {
1305                        let span_len = span.end - span.start;
1306                        if span.is_strikethrough {
1307                            elements.push(Element::Strikethrough {
1308                                content: span.content.clone(),
1309                                double: span.strikethrough_double,
1310                            });
1311                        } else if span.is_strong {
1312                            elements.push(Element::Bold {
1313                                content: span.content.clone(),
1314                                underscore: span.uses_underscore,
1315                            });
1316                        } else {
1317                            elements.push(Element::Italic {
1318                                content: span.content.clone(),
1319                                underscore: span.uses_underscore,
1320                            });
1321                        }
1322                        remaining = &remaining[span_len..];
1323                    } else {
1324                        // Fallback - shouldn't happen
1325                        elements.push(Element::Text(remaining[..1].to_string()));
1326                        remaining = &remaining[1..];
1327                    }
1328                }
1329                _ => {
1330                    // No special elements found, add all remaining text
1331                    elements.push(Element::Text(remaining.to_string()));
1332                    break;
1333                }
1334            }
1335        }
1336    }
1337
1338    elements
1339}
1340
1341fn should_insert_space_before_join(current: &str) -> bool {
1342    !current.is_empty()
1343        && !current.ends_with(' ')
1344        && !current.ends_with('(')
1345        && !current.ends_with('[')
1346        && !current.ends_with('-')
1347}
1348
1349/// Reflow elements for sentence-per-line mode
1350fn reflow_elements_sentence_per_line(
1351    elements: &[Element],
1352    custom_abbreviations: &Option<Vec<String>>,
1353    require_sentence_capital: bool,
1354) -> Vec<String> {
1355    let abbreviations = get_abbreviations(custom_abbreviations);
1356    let mut lines = Vec::new();
1357    let mut current_line = String::new();
1358
1359    for (idx, element) in elements.iter().enumerate() {
1360        let element_str = format!("{element}");
1361
1362        // For text elements, split into sentences
1363        if let Element::Text(text) = element {
1364            // Simply append text - it already has correct spacing from tokenization
1365            let combined = format!("{current_line}{text}");
1366            // Use the pre-computed abbreviations set to avoid redundant computation
1367            let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital);
1368
1369            if sentences.len() > 1 {
1370                // We found sentence boundaries
1371                for (i, sentence) in sentences.iter().enumerate() {
1372                    if i == 0 {
1373                        // First sentence might continue from previous elements
1374                        // But check if it ends with an abbreviation
1375                        let trimmed = sentence.trim();
1376
1377                        if text_ends_with_abbreviation(trimmed, &abbreviations) {
1378                            // Don't emit yet - this sentence ends with abbreviation, continue accumulating
1379                            current_line.clone_from(sentence);
1380                        } else {
1381                            // Normal case - emit the first sentence
1382                            lines.push(sentence.clone());
1383                            current_line.clear();
1384                        }
1385                    } else if i == sentences.len() - 1 {
1386                        // Last sentence: check if it's complete or incomplete
1387                        let trimmed = sentence.trim();
1388                        let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1389
1390                        if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1391                            // Complete sentence - emit it immediately
1392                            lines.push(sentence.clone());
1393                            current_line.clear();
1394                        } else {
1395                            // Incomplete sentence - save for next iteration
1396                            current_line.clone_from(sentence);
1397                        }
1398                    } else {
1399                        // Complete sentences in the middle
1400                        lines.push(sentence.clone());
1401                    }
1402                }
1403            } else {
1404                // Single sentence - check if it's complete
1405                let trimmed = combined.trim();
1406
1407                // If the combined result is only whitespace, don't accumulate it.
1408                // This prevents leading spaces on subsequent elements when lines
1409                // are joined with spaces during reflow iteration.
1410                if trimmed.is_empty() {
1411                    continue;
1412                }
1413
1414                let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
1415
1416                if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) {
1417                    // Complete single sentence - emit it
1418                    lines.push(trimmed.to_string());
1419                    current_line.clear();
1420                } else {
1421                    // Incomplete sentence - continue accumulating
1422                    current_line = combined;
1423                }
1424            }
1425        } else if let Element::Italic { content, underscore } = element {
1426            // Handle italic elements - may contain multiple sentences that need continuation
1427            let marker = if *underscore { "_" } else { "*" };
1428            handle_emphasis_sentence_split(
1429                content,
1430                marker,
1431                &abbreviations,
1432                require_sentence_capital,
1433                &mut current_line,
1434                &mut lines,
1435            );
1436        } else if let Element::Bold { content, underscore } = element {
1437            // Handle bold elements - may contain multiple sentences that need continuation
1438            let marker = if *underscore { "__" } else { "**" };
1439            handle_emphasis_sentence_split(
1440                content,
1441                marker,
1442                &abbreviations,
1443                require_sentence_capital,
1444                &mut current_line,
1445                &mut lines,
1446            );
1447        } else if let Element::Strikethrough { content, double } = element {
1448            // Handle strikethrough elements - may contain multiple sentences that need continuation
1449            handle_emphasis_sentence_split(
1450                content,
1451                if *double { "~~" } else { "~" },
1452                &abbreviations,
1453                require_sentence_capital,
1454                &mut current_line,
1455                &mut lines,
1456            );
1457        } else {
1458            // Non-text, non-emphasis elements (Code, Links, etc.)
1459            // Check if this element is adjacent to the preceding text (no space between)
1460            let is_adjacent = if idx > 0 {
1461                match &elements[idx - 1] {
1462                    Element::Text(t) => !t.is_empty() && !t.ends_with(char::is_whitespace),
1463                    _ => true,
1464                }
1465            } else {
1466                false
1467            };
1468
1469            // Add space before element if needed, but not for adjacent elements
1470            if !is_adjacent && should_insert_space_before_join(&current_line) {
1471                current_line.push(' ');
1472            }
1473            current_line.push_str(&element_str);
1474        }
1475    }
1476
1477    // Add any remaining content
1478    if !current_line.is_empty() {
1479        lines.push(current_line.trim().to_string());
1480    }
1481    lines
1482}
1483
1484/// Handle splitting emphasis content at sentence boundaries while preserving markers
1485fn handle_emphasis_sentence_split(
1486    content: &str,
1487    marker: &str,
1488    abbreviations: &HashSet<String>,
1489    require_sentence_capital: bool,
1490    current_line: &mut String,
1491    lines: &mut Vec<String>,
1492) {
1493    // Split the emphasis content into sentences
1494    let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital);
1495
1496    if sentences.len() <= 1 {
1497        // Single sentence or no boundaries - treat as atomic
1498        if should_insert_space_before_join(current_line) {
1499            current_line.push(' ');
1500        }
1501        current_line.push_str(marker);
1502        current_line.push_str(content);
1503        current_line.push_str(marker);
1504
1505        // Check if the emphasis content ends with sentence punctuation - if so, emit
1506        let trimmed = content.trim();
1507        let ends_with_punct = ends_with_sentence_punct(trimmed);
1508        if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1509            lines.push(current_line.clone());
1510            current_line.clear();
1511        }
1512    } else {
1513        // Multiple sentences - each gets its own emphasis markers
1514        for (i, sentence) in sentences.iter().enumerate() {
1515            let trimmed = sentence.trim();
1516            if trimmed.is_empty() {
1517                continue;
1518            }
1519
1520            if i == 0 {
1521                // First sentence: combine with current_line and emit
1522                if should_insert_space_before_join(current_line) {
1523                    current_line.push(' ');
1524                }
1525                current_line.push_str(marker);
1526                current_line.push_str(trimmed);
1527                current_line.push_str(marker);
1528
1529                // Check if this is a complete sentence
1530                let ends_with_punct = ends_with_sentence_punct(trimmed);
1531                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1532                    lines.push(current_line.clone());
1533                    current_line.clear();
1534                }
1535            } else if i == sentences.len() - 1 {
1536                // Last sentence: check if complete
1537                let ends_with_punct = ends_with_sentence_punct(trimmed);
1538
1539                let mut line = String::new();
1540                line.push_str(marker);
1541                line.push_str(trimmed);
1542                line.push_str(marker);
1543
1544                if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) {
1545                    lines.push(line);
1546                } else {
1547                    // Incomplete - keep in current_line for potential continuation
1548                    *current_line = line;
1549                }
1550            } else {
1551                // Middle sentences: emit with markers
1552                let mut line = String::new();
1553                line.push_str(marker);
1554                line.push_str(trimmed);
1555                line.push_str(marker);
1556                lines.push(line);
1557            }
1558        }
1559    }
1560}
1561
1562/// English break-words used for semantic line break splitting.
1563/// These are conjunctions and relative pronouns where a line break
1564/// reads naturally.
1565const BREAK_WORDS: &[&str] = &[
1566    "and",
1567    "or",
1568    "but",
1569    "nor",
1570    "yet",
1571    "so",
1572    "for",
1573    "which",
1574    "that",
1575    "because",
1576    "when",
1577    "if",
1578    "while",
1579    "where",
1580    "although",
1581    "though",
1582    "unless",
1583    "since",
1584    "after",
1585    "before",
1586    "until",
1587    "as",
1588    "once",
1589    "whether",
1590    "however",
1591    "therefore",
1592    "moreover",
1593    "furthermore",
1594    "nevertheless",
1595    "whereas",
1596];
1597
1598/// Check if a character is clause punctuation for semantic line breaks
1599fn is_clause_punctuation(c: char) -> bool {
1600    matches!(c, ',' | ';' | ':' | '\u{2014}') // comma, semicolon, colon, em dash
1601}
1602
1603/// Whether a clause-punctuation char at `chars[i]` is a legitimate break point.
1604///
1605/// A real clause boundary is followed by whitespace (or ends the text): `,;:`
1606/// with no following space sit *inside* a token (`16:9`, `key:value`, a MyST role
1607/// like `{cite:p}`) and must not be split there. The em dash (`—`) is exempt:
1608/// it commonly joins words with no surrounding spaces and breaking after it reads
1609/// naturally.
1610fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
1611    if chars[i] == '\u{2014}' {
1612        return true;
1613    }
1614    match chars.get(i + 1) {
1615        None => true,
1616        Some(next) => next.is_whitespace(),
1617    }
1618}
1619
1620/// Find the closing `)` that balances the `(` at the start of `slice`.
1621///
1622/// `offset` is the byte position of the `(` in the original full-line string;
1623/// it is used to translate local byte positions into global positions for
1624/// element-span lookups.  Parens inside markdown element spans are skipped so
1625/// that, e.g., the closing `)` of an inline link does not prematurely end the
1626/// scan.  The char's *start* byte (not byte-after) is used for the span check
1627/// so that closing element delimiters — which sit exactly at the span's
1628/// exclusive-end boundary — are correctly excluded.
1629///
1630/// Returns `(end_local, inner)` where `end_local` is the byte offset within
1631/// `slice` just past the closing `)`, and `inner` is the content between the
1632/// outermost `(` and `)`.
1633fn paren_group_end<'a>(slice: &'a str, element_spans: &[(usize, usize)], offset: usize) -> Option<(usize, &'a str)> {
1634    debug_assert!(slice.starts_with('('));
1635    let mut depth: i32 = 0;
1636    for (local_byte, c) in slice.char_indices() {
1637        let global_byte = offset + local_byte;
1638        // When depth > 0, skip parens that belong to a markdown element.
1639        // Use the char's start byte so that a closing element delimiter
1640        // (whose byte_after equals the span's exclusive end) is treated as
1641        // inside the element rather than outside it.
1642        if depth > 0 && is_inside_element(global_byte, element_spans) {
1643            continue;
1644        }
1645        match c {
1646            '(' => depth += 1,
1647            ')' => {
1648                depth -= 1;
1649                if depth == 0 {
1650                    let end = local_byte + 1;
1651                    let inner = &slice[1..local_byte];
1652                    return Some((end, inner));
1653                }
1654            }
1655            _ => {}
1656        }
1657    }
1658    None
1659}
1660
1661/// Split a line at a parenthetical boundary for semantic line breaks.
1662///
1663/// Two strategies are tried in order:
1664///
1665/// 1. **Leading parenthetical** — if the line begins with `(`, isolate the
1666///    entire balanced group on this line and start the rest on the next.
1667///    This handles lines produced by a prior split that placed a `(` at the
1668///    very beginning.
1669///
1670/// 2. **Mid-line parenthetical** — find the rightmost balanced `(…)` whose
1671///    content spans multiple words and whose preceding text fits within
1672///    `[min_first_len, line_length]`.  Split just before the `(` so the
1673///    parenthetical begins the following line.
1674///
1675/// Parentheses that fall inside markdown element spans (links, code, etc.)
1676/// are ignored in both strategies.
1677fn split_at_parenthetical(
1678    text: &str,
1679    line_length: usize,
1680    element_spans: &[(usize, usize)],
1681    length_mode: ReflowLengthMode,
1682) -> Option<(String, String)> {
1683    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1684
1685    // Strategy 1: text starts with '(' — isolate the parenthetical as its own line.
1686    if text.starts_with('(')
1687        && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
1688        && inner.contains(' ')
1689    {
1690        // If closing quotes or clause punctuation immediately follow the closing
1691        // ')', attach them to the parenthetical so the continuation line does
1692        // not start with a bare quote, comma, or semicolon.
1693        let tail = &text[end_local..];
1694        let attached_len = tail
1695            .char_indices()
1696            .take_while(|(_, c)| is_closing_quote(*c) || is_clause_punctuation(*c))
1697            .last()
1698            .map_or(0, |(idx, c)| idx + c.len_utf8());
1699        let first_end = end_local + attached_len;
1700        let rest_start = first_end;
1701        let first = &text[..first_end];
1702        let first_len = display_len(first, length_mode);
1703        // No MIN_SPLIT_RATIO check: a parenthetical unit is always a valid
1704        // semantic line regardless of its length.
1705        if first_len <= line_length {
1706            let rest = text[rest_start..].trim_start();
1707            if !rest.is_empty() {
1708                return Some((first.to_string(), rest.to_string()));
1709            }
1710        }
1711    }
1712
1713    // Strategy 2: find the rightmost multi-word '(' whose preceding text fits.
1714    let mut best_open_byte: Option<usize> = None;
1715    let mut pos = 0usize;
1716    while pos < text.len() {
1717        // '(' is ASCII so a single-byte comparison is safe in UTF-8.
1718        if text.as_bytes()[pos] != b'(' {
1719            let c = text[pos..].chars().next().unwrap();
1720            pos += c.len_utf8();
1721            continue;
1722        }
1723        // Skip '(' that are part of a markdown element (use start byte).
1724        if is_inside_element(pos, element_spans) {
1725            pos += 1;
1726            continue;
1727        }
1728        if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
1729            let first = text[..pos].trim_end();
1730            let first_len = display_len(first, length_mode);
1731            if !first.is_empty()
1732                && first_len >= min_first_len
1733                && first_len <= line_length
1734                && inner.contains(' ')
1735                && best_open_byte.is_none_or(|prev| pos > prev)
1736            {
1737                best_open_byte = Some(pos);
1738            }
1739            pos += end_local;
1740        } else {
1741            pos += 1;
1742        }
1743    }
1744
1745    let open_byte = best_open_byte?;
1746    let first = text[..open_byte].trim_end().to_string();
1747    let rest = text[open_byte..].to_string();
1748    if first.is_empty() || rest.trim().is_empty() {
1749        return None;
1750    }
1751    Some((first, rest))
1752}
1753
1754/// Compute element spans for a flat text representation of elements.
1755/// Returns Vec of (start, end) byte offsets for non-Text elements,
1756/// so we can check that a split position doesn't fall inside them.
1757fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> {
1758    let mut spans = Vec::new();
1759    let mut offset = 0;
1760    for element in elements {
1761        let rendered = format!("{element}");
1762        let len = rendered.len();
1763        if !matches!(element, Element::Text(_)) {
1764            spans.push((offset, offset + len));
1765        }
1766        offset += len;
1767    }
1768    spans
1769}
1770
1771/// Check if a byte position falls inside any non-Text element span
1772fn is_inside_element(pos: usize, spans: &[(usize, usize)]) -> bool {
1773    spans.iter().any(|(start, end)| pos > *start && pos < *end)
1774}
1775
1776/// Minimum fraction of line_length that the first part of a split must occupy.
1777/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
1778const MIN_SPLIT_RATIO: f64 = 0.3;
1779
1780/// Split a line at the latest clause punctuation that keeps the first part
1781/// within `line_length`. Returns None if no valid split point exists or if
1782/// the split would create an unreasonably short first line.
1783fn split_at_clause_punctuation(
1784    text: &str,
1785    line_length: usize,
1786    element_spans: &[(usize, usize)],
1787    length_mode: ReflowLengthMode,
1788) -> Option<(String, String)> {
1789    let chars: Vec<char> = text.chars().collect();
1790    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1791
1792    // Find the char index where accumulated display width exceeds line_length
1793    let mut width_acc = 0;
1794    let mut search_end_char = 0;
1795    for (idx, &c) in chars.iter().enumerate() {
1796        let c_width = display_len(&c.to_string(), length_mode);
1797        if width_acc + c_width > line_length {
1798            break;
1799        }
1800        width_acc += c_width;
1801        search_end_char = idx + 1;
1802    }
1803
1804    // Scan backwards tracking parenthesis depth to skip clause punctuation
1805    // inside plain-text parenthetical groups.  Scanning right-to-left means
1806    // ')' opens a depth level and '(' closes it.  Parens that belong to a
1807    // markdown element are excluded using the char's start byte (not byte-after)
1808    // so that closing element delimiters at the span boundary are correctly
1809    // treated as part of the element.
1810    let mut paren_depth: i32 = 0;
1811    let mut best_pos = None;
1812    for i in (0..search_end_char).rev() {
1813        // Start byte of char i (for paren element check)
1814        let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
1815        // Byte just after char i (for clause punctuation element check — existing convention)
1816        let byte_after: usize = byte_start + chars[i].len_utf8();
1817
1818        if !is_inside_element(byte_start, element_spans) {
1819            match chars[i] {
1820                ')' => paren_depth += 1,
1821                '(' => paren_depth = paren_depth.saturating_sub(1),
1822                _ => {}
1823            }
1824        }
1825
1826        if paren_depth == 0
1827            && is_clause_punctuation(chars[i])
1828            && clause_break_allowed_after(&chars, i)
1829            && !is_inside_element(byte_after, element_spans)
1830        {
1831            best_pos = Some(i);
1832            break;
1833        }
1834    }
1835
1836    let pos = best_pos?;
1837
1838    // Reject splits that create very short first lines
1839    let first: String = chars[..=pos].iter().collect();
1840    let first_display_len = display_len(&first, length_mode);
1841    if first_display_len < min_first_len {
1842        return None;
1843    }
1844
1845    // Split after the punctuation character
1846    let rest: String = chars[pos + 1..].iter().collect();
1847    let rest = rest.trim_start().to_string();
1848
1849    if rest.is_empty() {
1850        return None;
1851    }
1852
1853    Some((first, rest))
1854}
1855
1856/// Compute plain-text paren-depth at each byte offset in `text`.
1857///
1858/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
1859/// nesting depth at byte `i` — counting only `(` and `)` that fall
1860/// outside markdown element spans.  This lets callers quickly check
1861/// whether a byte position lies inside a plain-text parenthetical group.
1862fn paren_depth_map(text: &str, element_spans: &[(usize, usize)]) -> Vec<i32> {
1863    let mut map = vec![0i32; text.len()];
1864    let mut depth = 0i32;
1865    for (byte, c) in text.char_indices() {
1866        if !is_inside_element(byte, element_spans) {
1867            match c {
1868                '(' => depth += 1,
1869                ')' => depth = depth.saturating_sub(1),
1870                _ => {}
1871            }
1872        }
1873        // Fill the depth value for every byte of this (possibly multi-byte) char.
1874        let end = (byte + c.len_utf8()).min(map.len());
1875        for slot in &mut map[byte..end] {
1876            *slot = depth;
1877        }
1878    }
1879    map
1880}
1881
1882/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
1883/// group — i.e. it starts with `(`, ends with `)` (possibly followed by
1884/// clause punctuation), has balanced parens throughout, and the inner content
1885/// contains at least one space (matching the ≥2-word threshold used by
1886/// `split_at_parenthetical` when deciding to split).
1887///
1888/// Used to prevent the short-line merge step from collapsing intentional
1889/// parenthetical splits back into the previous line.
1890fn is_standalone_parenthetical(line: &str) -> bool {
1891    let trimmed = line.trim();
1892    if !trimmed.starts_with('(') {
1893        return false;
1894    }
1895    // Strip optional trailing clause punctuation to find the real end.
1896    let core = trimmed.trim_end_matches(|c: char| is_clause_punctuation(c));
1897    if !core.ends_with(')') {
1898        return false;
1899    }
1900    // Inner content must span multiple words (same threshold as split_at_parenthetical).
1901    let inner = &core[1..core.len() - 1];
1902    if !inner.contains(' ') {
1903        return false;
1904    }
1905    // Verify the parens are balanced (depth returns to 0 at the last ')').
1906    let mut depth = 0i32;
1907    for c in core.chars() {
1908        match c {
1909            '(' => depth += 1,
1910            ')' => depth -= 1,
1911            _ => {}
1912        }
1913        if depth < 0 {
1914            return false;
1915        }
1916    }
1917    depth == 0
1918}
1919
1920/// Split a line before the latest break-word that keeps the first part
1921/// within `line_length`. Returns None if no valid split point exists or if
1922/// the split would create an unreasonably short first line.
1923fn split_at_break_word(
1924    text: &str,
1925    line_length: usize,
1926    element_spans: &[(usize, usize)],
1927    length_mode: ReflowLengthMode,
1928) -> Option<(String, String)> {
1929    let lower = text.to_lowercase();
1930    let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
1931    let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
1932
1933    // Build a paren-depth map so we can skip break-words inside plain-text
1934    // parenthetical groups (matching the protection added to split_at_clause_punctuation).
1935    let depth_map = paren_depth_map(text, element_spans);
1936
1937    for &word in BREAK_WORDS {
1938        let mut search_start = 0;
1939        while let Some(pos) = lower[search_start..].find(word) {
1940            let abs_pos = search_start + pos;
1941
1942            // Verify it's a word boundary: preceded by space, followed by space
1943            let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
1944            let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
1945
1946            if preceded_by_space && followed_by_space {
1947                // The break goes BEFORE the word, so first part ends at abs_pos - 1
1948                let first_part = text[..abs_pos].trim_end();
1949                let first_part_len = display_len(first_part, length_mode);
1950
1951                // Skip break-words inside plain-text parenthetical groups.
1952                let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
1953
1954                if first_part_len >= min_first_len
1955                    && first_part_len <= line_length
1956                    && !is_inside_element(abs_pos, element_spans)
1957                    && !inside_paren
1958                {
1959                    // Prefer the latest valid split point
1960                    if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
1961                        best_split = Some((abs_pos, word.len()));
1962                    }
1963                }
1964            }
1965
1966            search_start = abs_pos + word.len();
1967        }
1968    }
1969
1970    let (byte_start, _word_len) = best_split?;
1971
1972    let first = text[..byte_start].trim_end().to_string();
1973    let rest = text[byte_start..].to_string();
1974
1975    if first.is_empty() || rest.trim().is_empty() {
1976        return None;
1977    }
1978
1979    Some((first, rest))
1980}
1981
1982/// Cascade-split a line that exceeds line_length.
1983/// Tries parenthetical boundaries, then clause punctuation, then break-words,
1984/// then word wrap.
1985///
1986/// This is iterative rather than recursive so a single very long line (tens of
1987/// thousands of words) cannot overflow the stack. Each accepted split shrinks
1988/// the remaining text by a non-empty prefix, so the loop always makes progress.
1989/// The whole line is parsed into markdown elements once up front; every
1990/// remaining suffix reuses those element spans (re-based to the suffix offset)
1991/// instead of re-parsing, which keeps repeated element parsing out of the loop.
1992fn cascade_split_line(
1993    text: &str,
1994    line_length: usize,
1995    abbreviations: &Option<Vec<String>>,
1996    length_mode: ReflowLengthMode,
1997    attr_lists: bool,
1998    myst_roles: bool,
1999    defined_references: Option<&HashSet<String>>,
2000) -> Vec<String> {
2001    if line_length == 0 || display_len(text, length_mode) <= line_length {
2002        return vec![text.to_string()];
2003    }
2004
2005    let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
2006    let element_spans = compute_element_spans(&elements);
2007
2008    // Element spans of the remaining suffix `text[start..]`, re-based so their
2009    // offsets are relative to the suffix. Split points never fall inside an
2010    // element, so every span lies wholly before or wholly at/after `start`.
2011    let rebased_spans = |start: usize| -> Vec<(usize, usize)> {
2012        if start == 0 {
2013            return element_spans.clone();
2014        }
2015        element_spans
2016            .iter()
2017            .filter(|&&(_, end)| end > start)
2018            .map(|&(s, e)| (s.saturating_sub(start), e.saturating_sub(start)))
2019            .collect()
2020    };
2021
2022    let mut result = Vec::new();
2023    let mut start = 0usize;
2024
2025    loop {
2026        let remaining = &text[start..];
2027        if display_len(remaining, length_mode) <= line_length {
2028            result.push(remaining.to_string());
2029            return result;
2030        }
2031
2032        let spans = rebased_spans(start);
2033
2034        // `rest` is always a suffix of `remaining` (the splitters only trim its
2035        // leading whitespace), so `remaining.len() - rest.len()` is the number of
2036        // bytes consumed, and the new absolute offset is `start + consumed`.
2037        let split = split_at_parenthetical(remaining, line_length, &spans, length_mode)
2038            .or_else(|| split_at_clause_punctuation(remaining, line_length, &spans, length_mode))
2039            .or_else(|| split_at_break_word(remaining, line_length, &spans, length_mode));
2040
2041        if let Some((first, rest)) = split {
2042            let consumed = remaining.len().saturating_sub(rest.len());
2043            // Defensive: a zero-length advance would loop forever. Splitters only
2044            // return a non-empty `first`, so this never triggers, but guard anyway.
2045            if consumed == 0 {
2046                break;
2047            }
2048            result.push(first);
2049            start += consumed;
2050            continue;
2051        }
2052
2053        // No semantic split point: word-wrap the remaining suffix and finish.
2054        break;
2055    }
2056
2057    // Fallback: word wrap the still-oversized suffix using reflow_elements.
2058    let options = ReflowOptions {
2059        line_length,
2060        break_on_sentences: false,
2061        preserve_breaks: false,
2062        sentence_per_line: false,
2063        semantic_line_breaks: false,
2064        abbreviations: abbreviations.clone(),
2065        length_mode,
2066        attr_lists,
2067        myst_roles,
2068        require_sentence_capital: true,
2069        max_list_continuation_indent: None,
2070        // Unused here: this fallback arranges already-parsed elements and never
2071        // re-parses links, so shortcut definedness is never consulted.
2072        defined_references: None,
2073    };
2074    let remaining = &text[start..];
2075    let tail_elements = if start == 0 {
2076        elements
2077    } else {
2078        parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
2079    };
2080    result.extend(reflow_elements(&tail_elements, &options));
2081    result
2082}
2083
2084/// Reflow elements using semantic line breaks strategy:
2085/// 1. Split at sentence boundaries (always)
2086/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
2087fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2088    // Step 1: Split into sentences using existing sentence-per-line logic
2089    let sentence_lines =
2090        reflow_elements_sentence_per_line(elements, &options.abbreviations, options.require_sentence_capital);
2091
2092    // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
2093    // When line_length is 0 (unlimited), skip cascading — sentence splits only
2094    if options.line_length == 0 {
2095        return sentence_lines;
2096    }
2097
2098    let length_mode = options.length_mode;
2099    let mut result = Vec::new();
2100    for line in sentence_lines {
2101        if display_len(&line, length_mode) <= options.line_length {
2102            result.push(line);
2103        } else {
2104            result.extend(cascade_split_line(
2105                &line,
2106                options.line_length,
2107                &options.abbreviations,
2108                length_mode,
2109                options.attr_lists,
2110                options.myst_roles,
2111                options.defined_references.as_ref(),
2112            ));
2113        }
2114    }
2115
2116    // Step 3: Merge very short trailing lines back into the previous line.
2117    // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
2118    let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
2119    let mut merged: Vec<String> = Vec::with_capacity(result.len());
2120    for line in result {
2121        if !merged.is_empty() && display_len(&line, length_mode) < min_line_len && !line.trim().is_empty() {
2122            // Don't merge a line that is itself a standalone parenthetical group —
2123            // it was placed on its own line intentionally by split_at_parenthetical.
2124            if is_standalone_parenthetical(&line) {
2125                merged.push(line);
2126                continue;
2127            }
2128
2129            // Don't merge across sentence boundaries — sentence splits are intentional
2130            let prev_ends_at_sentence = {
2131                let trimmed = merged.last().unwrap().trim_end();
2132                trimmed
2133                    .chars()
2134                    .rev()
2135                    .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
2136                    .is_some_and(|c| matches!(c, '.' | '!' | '?'))
2137            };
2138
2139            if !prev_ends_at_sentence {
2140                let prev = merged.last_mut().unwrap();
2141                let combined = format!("{prev} {line}");
2142                // Only merge if the combined line fits within the limit
2143                if display_len(&combined, length_mode) <= options.line_length {
2144                    *prev = combined;
2145                    continue;
2146                }
2147            }
2148        }
2149        merged.push(line);
2150    }
2151    merged
2152}
2153
2154/// Find the last space in `line` that is safe to split at.
2155/// Safe spaces are those NOT inside rendered non-Text elements.
2156/// `element_spans` contains (start, end) byte ranges of non-Text elements in the line.
2157/// Find the last space in `line` that is not inside any element span.
2158/// Spans use exclusive bounds (pos > start && pos < end) because element
2159/// delimiters (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never
2160/// spaces, so only interior positions need protection.
2161fn rfind_safe_space(line: &str, element_spans: &[(usize, usize)]) -> Option<usize> {
2162    line.char_indices()
2163        .rev()
2164        .map(|(pos, _)| pos)
2165        .find(|&pos| line.as_bytes()[pos] == b' ' && !element_spans.iter().any(|(s, e)| pos > *s && pos < *e))
2166}
2167
2168/// Reflow elements into lines that fit within the line length
2169fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
2170    let mut lines = Vec::new();
2171    let mut current_line = String::new();
2172    let mut current_length = 0;
2173    // Track byte spans of non-Text elements in current_line for safe splitting
2174    let mut current_line_element_spans: Vec<(usize, usize)> = Vec::new();
2175    let length_mode = options.length_mode;
2176
2177    for (idx, element) in elements.iter().enumerate() {
2178        // Derive the display width from the already-formatted string rather than
2179        // formatting the element a second time just to measure it.
2180        let element_str = format!("{element}");
2181        let element_len = display_len(&element_str, length_mode);
2182
2183        // Determine adjacency from the original elements, not from current_line.
2184        // Elements are adjacent when there's no whitespace between them in the source:
2185        // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
2186        // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
2187        // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
2188        let is_adjacent_to_prev = if idx > 0 {
2189            match (&elements[idx - 1], element) {
2190                (Element::Text(t), _) => !t.is_empty() && !t.ends_with(char::is_whitespace),
2191                (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(char::is_whitespace),
2192                _ => true,
2193            }
2194        } else {
2195            false
2196        };
2197
2198        // For text elements that might need breaking
2199        if let Element::Text(text) = element {
2200            // Check if original text had leading whitespace
2201            let has_leading_space = text.starts_with(char::is_whitespace);
2202            // If this is a text element, always process it word by word
2203            let words: Vec<&str> = text.split_whitespace().collect();
2204
2205            for (i, word) in words.iter().enumerate() {
2206                let word_len = display_len(word, length_mode);
2207                // Check if this "word" is just punctuation that should stay attached
2208                let is_trailing_punct = word
2209                    .chars()
2210                    .all(|c| matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}'));
2211
2212                // First word of text adjacent to preceding non-text element
2213                // must stay attached (e.g., shortcode followed by punctuation or text)
2214                let is_first_adjacent = i == 0 && is_adjacent_to_prev;
2215
2216                if is_first_adjacent {
2217                    // Attach directly without space, preventing line break
2218                    if current_length + word_len > options.line_length && current_length > 0 {
2219                        // Would exceed — break before the adjacent group
2220                        // Use element-aware space search to avoid splitting inside links/code/etc.
2221                        if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2222                            let before = current_line[..last_space].trim_end().to_string();
2223                            let after = current_line[last_space + 1..].to_string();
2224                            lines.push(before);
2225                            current_line = format!("{after}{word}");
2226                            current_length = display_len(&current_line, length_mode);
2227                            current_line_element_spans.clear();
2228                        } else {
2229                            current_line.push_str(word);
2230                            current_length += word_len;
2231                        }
2232                    } else {
2233                        current_line.push_str(word);
2234                        current_length += word_len;
2235                    }
2236                } else if current_length > 0
2237                    && current_length + 1 + word_len > options.line_length
2238                    && !is_trailing_punct
2239                {
2240                    // Start a new line (but never for trailing punctuation)
2241                    lines.push(current_line.trim().to_string());
2242                    current_line = word.to_string();
2243                    current_length = word_len;
2244                    current_line_element_spans.clear();
2245                } else {
2246                    // Add a space only where the source had whitespace at this position.
2247                    // For the first word of a text run (i == 0) that means the source had a
2248                    // leading space — and reaching this branch already implies the word is
2249                    // not adjacent to the previous element, so the space is real and must be
2250                    // kept even for punctuation. Suppressing it here would delete the space
2251                    // after an inline element, e.g. `` `code` } `` -> `` `code`} ``. The
2252                    // no-space (adjacent) case is handled above by `is_first_adjacent`.
2253                    // Within a text run (i > 0) trailing punctuation still attaches to the
2254                    // preceding word.
2255                    let add_space = current_length > 0 && if i == 0 { has_leading_space } else { !is_trailing_punct };
2256                    if add_space {
2257                        current_line.push(' ');
2258                        current_length += 1;
2259                    }
2260                    current_line.push_str(word);
2261                    current_length += word_len;
2262                }
2263            }
2264        } else if matches!(
2265            element,
2266            Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
2267        ) && element_len > options.line_length
2268        {
2269            // Italic, bold, and strikethrough with content longer than line_length need word wrapping.
2270            // Split content word-by-word, attach the opening marker to the first word
2271            // and the closing marker to the last word.
2272            let (content, marker): (&str, &str) = match element {
2273                Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }),
2274                Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }),
2275                Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }),
2276                _ => unreachable!(),
2277            };
2278
2279            let words: Vec<&str> = content.split_whitespace().collect();
2280            let n = words.len();
2281
2282            if n == 0 {
2283                // Empty span — treat as atomic
2284                let full = format!("{marker}{marker}");
2285                let full_len = display_len(&full, length_mode);
2286                if !is_adjacent_to_prev && current_length > 0 {
2287                    current_line.push(' ');
2288                    current_length += 1;
2289                }
2290                current_line.push_str(&full);
2291                current_length += full_len;
2292            } else {
2293                for (i, word) in words.iter().enumerate() {
2294                    let is_first = i == 0;
2295                    let is_last = i == n - 1;
2296                    let word_str: String = match (is_first, is_last) {
2297                        (true, true) => format!("{marker}{word}{marker}"),
2298                        (true, false) => format!("{marker}{word}"),
2299                        (false, true) => format!("{word}{marker}"),
2300                        (false, false) => word.to_string(),
2301                    };
2302                    let word_len = display_len(&word_str, length_mode);
2303
2304                    let needs_space = if is_first {
2305                        !is_adjacent_to_prev && current_length > 0
2306                    } else {
2307                        current_length > 0
2308                    };
2309
2310                    if needs_space && current_length + 1 + word_len > options.line_length {
2311                        lines.push(current_line.trim_end().to_string());
2312                        current_line = word_str;
2313                        current_length = word_len;
2314                        current_line_element_spans.clear();
2315                    } else {
2316                        if needs_space {
2317                            current_line.push(' ');
2318                            current_length += 1;
2319                        }
2320                        current_line.push_str(&word_str);
2321                        current_length += word_len;
2322                    }
2323                }
2324            }
2325        } else {
2326            // For non-text elements (code, links, references), treat as atomic units
2327            // These should never be broken across lines
2328
2329            if is_adjacent_to_prev {
2330                // Adjacent to preceding text — attach directly without space
2331                if current_length + element_len > options.line_length {
2332                    // Would exceed limit — break before the adjacent word group
2333                    // Use element-aware space search to avoid splitting inside links/code/etc.
2334                    if let Some(last_space) = rfind_safe_space(&current_line, &current_line_element_spans) {
2335                        let before = current_line[..last_space].trim_end().to_string();
2336                        let after = current_line[last_space + 1..].to_string();
2337                        lines.push(before);
2338                        current_line = format!("{after}{element_str}");
2339                        current_length = display_len(&current_line, length_mode);
2340                        current_line_element_spans.clear();
2341                        // Record the element span in the new current_line
2342                        let start = after.len();
2343                        current_line_element_spans.push((start, start + element_str.len()));
2344                    } else {
2345                        // No safe space to break at — accept the long line
2346                        let start = current_line.len();
2347                        current_line.push_str(&element_str);
2348                        current_length += element_len;
2349                        current_line_element_spans.push((start, current_line.len()));
2350                    }
2351                } else {
2352                    let start = current_line.len();
2353                    current_line.push_str(&element_str);
2354                    current_length += element_len;
2355                    current_line_element_spans.push((start, current_line.len()));
2356                }
2357            } else if current_length > 0 && current_length + 1 + element_len > options.line_length {
2358                // Not adjacent, would exceed — start new line
2359                lines.push(current_line.trim().to_string());
2360                current_line.clone_from(&element_str);
2361                current_length = element_len;
2362                current_line_element_spans.clear();
2363                current_line_element_spans.push((0, element_str.len()));
2364            } else {
2365                // Not adjacent, fits — add with space
2366                let ends_with_opener =
2367                    current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
2368                if current_length > 0 && !ends_with_opener {
2369                    current_line.push(' ');
2370                    current_length += 1;
2371                }
2372                let start = current_line.len();
2373                current_line.push_str(&element_str);
2374                current_length += element_len;
2375                current_line_element_spans.push((start, current_line.len()));
2376            }
2377        }
2378    }
2379
2380    // Don't forget the last line
2381    if !current_line.is_empty() {
2382        lines.push(current_line.trim_end().to_string());
2383    }
2384
2385    lines
2386}
2387
2388/// Reflow markdown content preserving structure
2389pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
2390    let lines: Vec<&str> = content.lines().collect();
2391    let mut result = Vec::new();
2392    let mut i = 0;
2393
2394    while i < lines.len() {
2395        let line = lines[i];
2396        let trimmed = line.trim();
2397
2398        // Preserve empty lines
2399        if trimmed.is_empty() {
2400            result.push(String::new());
2401            i += 1;
2402            continue;
2403        }
2404
2405        // Preserve headings as-is
2406        if trimmed.starts_with('#') {
2407            result.push(line.to_string());
2408            i += 1;
2409            continue;
2410        }
2411
2412        // Preserve Quarto/Pandoc div markers (:::) as-is
2413        if trimmed.starts_with(":::") {
2414            result.push(line.to_string());
2415            i += 1;
2416            continue;
2417        }
2418
2419        // Preserve fenced code blocks
2420        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2421            result.push(line.to_string());
2422            i += 1;
2423            // Copy lines until closing fence
2424            while i < lines.len() {
2425                result.push(lines[i].to_string());
2426                if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
2427                    i += 1;
2428                    break;
2429                }
2430                i += 1;
2431            }
2432            continue;
2433        }
2434
2435        // Preserve indented code blocks (4+ columns accounting for tab expansion)
2436        if calculate_indentation_width_default(line) >= 4 {
2437            // Collect all consecutive indented lines
2438            result.push(line.to_string());
2439            i += 1;
2440            while i < lines.len() {
2441                let next_line = lines[i];
2442                // Continue if next line is also indented or empty (empty lines in code blocks are ok)
2443                if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
2444                    result.push(next_line.to_string());
2445                    i += 1;
2446                } else {
2447                    break;
2448                }
2449            }
2450            continue;
2451        }
2452
2453        // Preserve block quotes (but reflow their content)
2454        if trimmed.starts_with('>') {
2455            // find() returns byte position which is correct for str slicing
2456            // The unwrap is safe because we already verified trimmed starts with '>'
2457            let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
2458            let quote_prefix = line[0..=gt_pos].to_string();
2459            let quote_content = &line[quote_prefix.len()..].trim_start();
2460
2461            let reflowed = reflow_line(quote_content, options);
2462            for reflowed_line in &reflowed {
2463                result.push(format!("{quote_prefix} {reflowed_line}"));
2464            }
2465            i += 1;
2466            continue;
2467        }
2468
2469        // Preserve horizontal rules first (before checking for lists)
2470        if is_horizontal_rule(trimmed) {
2471            result.push(line.to_string());
2472            i += 1;
2473            continue;
2474        }
2475
2476        // Preserve lists (but not horizontal rules)
2477        if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
2478            // Find the list marker and preserve indentation
2479            let indent = line.len() - line.trim_start().len();
2480            let indent_str = " ".repeat(indent);
2481
2482            // For numbered lists, find the period and the space after it
2483            // For bullet lists, find the marker and the space after it
2484            let mut marker_end = indent;
2485            let mut content_start = indent;
2486
2487            if trimmed.chars().next().is_some_and(char::is_numeric) {
2488                // Numbered list: find the period
2489                if let Some(period_pos) = line[indent..].find('.') {
2490                    marker_end = indent + period_pos + 1; // Include the period
2491                    content_start = marker_end;
2492                    // Skip any spaces after the period to find content start
2493                    // Use byte-based check since content_start is a byte index
2494                    // This is safe because space is ASCII (single byte)
2495                    while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2496                        content_start += 1;
2497                    }
2498                }
2499            } else {
2500                // Bullet list: marker is single character
2501                marker_end = indent + 1; // Just the marker character
2502                content_start = marker_end;
2503                // Skip any spaces after the marker
2504                // Use byte-based check since content_start is a byte index
2505                // This is safe because space is ASCII (single byte)
2506                while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
2507                    content_start += 1;
2508                }
2509            }
2510
2511            // Minimum indent for continuation lines (based on list marker, before checkbox)
2512            let min_continuation_indent = content_start;
2513
2514            // Detect checkbox/task list markers: [ ], [x], [X]
2515            // GFM task lists work with both unordered and ordered lists
2516            let rest = &line[content_start..];
2517            if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
2518                marker_end = content_start + 3; // Include the checkbox `[ ]`
2519                content_start += 4; // Skip past `[ ] `
2520            }
2521
2522            let marker = &line[indent..marker_end];
2523
2524            // Collect all content for this list item (including continuation lines)
2525            // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
2526            let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
2527            i += 1;
2528
2529            // Collect continuation lines (indented lines that are part of this list item)
2530            // Use the base marker indent (not checkbox-extended) for collection,
2531            // since users may indent continuations to the bullet level, not the checkbox level
2532            while i < lines.len() {
2533                let next_line = lines[i];
2534                let next_trimmed = next_line.trim();
2535
2536                // Stop if we hit an empty line or another list item or special block
2537                if is_block_boundary(next_trimmed) {
2538                    break;
2539                }
2540
2541                // Check if this line is indented (continuation of list item)
2542                let next_indent = next_line.len() - next_line.trim_start().len();
2543                if next_indent >= min_continuation_indent {
2544                    // This is a continuation line - add its content
2545                    // Preserve hard breaks while trimming excessive whitespace
2546                    let trimmed_start = next_line.trim_start();
2547                    list_content.push(trim_preserving_hard_break(trimmed_start));
2548                    i += 1;
2549                } else {
2550                    // Not indented enough, not part of this list item
2551                    break;
2552                }
2553            }
2554
2555            // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
2556            // Hard breaks should prevent joining with the next line
2557            let combined_content = if options.preserve_breaks {
2558                list_content[0].clone()
2559            } else {
2560                // Check if any lines have hard breaks - if so, preserve the structure
2561                let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
2562                if has_hard_breaks {
2563                    // Don't join lines with hard breaks - keep them separate with newlines
2564                    list_content.join("\n")
2565                } else {
2566                    // No hard breaks, safe to join with spaces
2567                    list_content.join(" ")
2568                }
2569            };
2570
2571            // Calculate the proper indentation for continuation lines
2572            let trimmed_marker = marker;
2573            let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
2574                // Cap the relative indent (past the nesting level) to max_indent,
2575                // then add back the nesting indent so nested items stay correct
2576                indent + (content_start - indent).min(max_indent)
2577            } else {
2578                content_start
2579            };
2580
2581            // Adjust line length to account for list marker and space
2582            let prefix_length = indent + trimmed_marker.len() + 1;
2583
2584            // Create adjusted options with reduced line length
2585            let adjusted_options = ReflowOptions {
2586                line_length: options.line_length.saturating_sub(prefix_length),
2587                ..options.clone()
2588            };
2589
2590            let reflowed = reflow_line(&combined_content, &adjusted_options);
2591            for (j, reflowed_line) in reflowed.iter().enumerate() {
2592                if j == 0 {
2593                    result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
2594                } else {
2595                    // Continuation lines aligned with text after marker
2596                    let continuation_indent = " ".repeat(continuation_spaces);
2597                    result.push(format!("{continuation_indent}{reflowed_line}"));
2598                }
2599            }
2600            continue;
2601        }
2602
2603        // Preserve tables
2604        if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
2605            result.push(line.to_string());
2606            i += 1;
2607            continue;
2608        }
2609
2610        // Preserve reference definitions
2611        if trimmed.starts_with('[') && line.contains("]:") {
2612            result.push(line.to_string());
2613            i += 1;
2614            continue;
2615        }
2616
2617        // Preserve definition list items (extended markdown)
2618        if is_definition_list_item(trimmed) {
2619            result.push(line.to_string());
2620            i += 1;
2621            continue;
2622        }
2623
2624        // Check if this is a single line that doesn't need processing
2625        let mut is_single_line_paragraph = true;
2626        if i + 1 < lines.len() {
2627            let next_trimmed = lines[i + 1].trim();
2628            // Check if next line continues this paragraph
2629            if !is_block_boundary(next_trimmed) {
2630                is_single_line_paragraph = false;
2631            }
2632        }
2633
2634        // If it's a single line that fits, just add it as-is
2635        if is_single_line_paragraph && display_len(line, options.length_mode) <= options.line_length {
2636            result.push(line.to_string());
2637            i += 1;
2638            continue;
2639        }
2640
2641        // For regular paragraphs, collect consecutive lines
2642        let mut paragraph_parts = Vec::new();
2643        let mut current_part = vec![line];
2644        i += 1;
2645
2646        // If preserve_breaks is true, treat each line separately
2647        if options.preserve_breaks {
2648            // Don't collect consecutive lines - just reflow this single line
2649            let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
2650                Some("\\")
2651            } else if line.ends_with("  ") {
2652                Some("  ")
2653            } else {
2654                None
2655            };
2656            let reflowed = reflow_line(line, options);
2657
2658            // Preserve hard breaks (two trailing spaces or backslash)
2659            if let Some(break_marker) = hard_break_type {
2660                if !reflowed.is_empty() {
2661                    let mut reflowed_with_break = reflowed;
2662                    let last_idx = reflowed_with_break.len() - 1;
2663                    if !has_hard_break(&reflowed_with_break[last_idx]) {
2664                        reflowed_with_break[last_idx].push_str(break_marker);
2665                    }
2666                    result.extend(reflowed_with_break);
2667                }
2668            } else {
2669                result.extend(reflowed);
2670            }
2671        } else {
2672            // Original behavior: collect consecutive lines into a paragraph
2673            while i < lines.len() {
2674                let prev_line = if !current_part.is_empty() {
2675                    current_part.last().unwrap()
2676                } else {
2677                    ""
2678                };
2679                let next_line = lines[i];
2680                let next_trimmed = next_line.trim();
2681
2682                // Stop at empty lines or special blocks
2683                if is_block_boundary(next_trimmed) {
2684                    break;
2685                }
2686
2687                // Check if previous line ends with hard break (two spaces or backslash)
2688                // or is a complete sentence in sentence_per_line mode
2689                let prev_trimmed = prev_line.trim();
2690                let abbreviations = get_abbreviations(&options.abbreviations);
2691                let ends_with_sentence = (prev_trimmed.ends_with('.')
2692                    || prev_trimmed.ends_with('!')
2693                    || prev_trimmed.ends_with('?')
2694                    || prev_trimmed.ends_with(".*")
2695                    || prev_trimmed.ends_with("!*")
2696                    || prev_trimmed.ends_with("?*")
2697                    || prev_trimmed.ends_with("._")
2698                    || prev_trimmed.ends_with("!_")
2699                    || prev_trimmed.ends_with("?_")
2700                    // Quote-terminated sentences (straight and curly quotes)
2701                    || prev_trimmed.ends_with(".\"")
2702                    || prev_trimmed.ends_with("!\"")
2703                    || prev_trimmed.ends_with("?\"")
2704                    || prev_trimmed.ends_with(".'")
2705                    || prev_trimmed.ends_with("!'")
2706                    || prev_trimmed.ends_with("?'")
2707                    || prev_trimmed.ends_with(".\u{201D}")
2708                    || prev_trimmed.ends_with("!\u{201D}")
2709                    || prev_trimmed.ends_with("?\u{201D}")
2710                    || prev_trimmed.ends_with(".\u{2019}")
2711                    || prev_trimmed.ends_with("!\u{2019}")
2712                    || prev_trimmed.ends_with("?\u{2019}"))
2713                    && !text_ends_with_abbreviation(
2714                        prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
2715                        &abbreviations,
2716                    );
2717
2718                if has_hard_break(prev_line) || (options.sentence_per_line && ends_with_sentence) {
2719                    // Start a new part after hard break or complete sentence
2720                    paragraph_parts.push(current_part.join(" "));
2721                    current_part = vec![next_line];
2722                } else {
2723                    current_part.push(next_line);
2724                }
2725                i += 1;
2726            }
2727
2728            // Add the last part
2729            if !current_part.is_empty() {
2730                if current_part.len() == 1 {
2731                    // Single line, don't add trailing space
2732                    paragraph_parts.push(current_part[0].to_string());
2733                } else {
2734                    paragraph_parts.push(current_part.join(" "));
2735                }
2736            }
2737
2738            // Reflow each part separately, preserving hard breaks
2739            for (j, part) in paragraph_parts.iter().enumerate() {
2740                let reflowed = reflow_line(part, options);
2741                result.extend(reflowed);
2742
2743                // Preserve hard break by ensuring last line of part ends with hard break marker
2744                // Use two spaces as the default hard break format for reflows
2745                // But don't add hard breaks in sentence_per_line mode - lines are already separate
2746                if j < paragraph_parts.len() - 1 && !result.is_empty() && !options.sentence_per_line {
2747                    let last_idx = result.len() - 1;
2748                    if !has_hard_break(&result[last_idx]) {
2749                        result[last_idx].push_str("  ");
2750                    }
2751                }
2752            }
2753        }
2754    }
2755
2756    // Preserve trailing newline if the original content had one
2757    let result_text = result.join("\n");
2758    if content.ends_with('\n') && !result_text.ends_with('\n') {
2759        format!("{result_text}\n")
2760    } else {
2761        result_text
2762    }
2763}
2764
2765/// Information about a reflowed paragraph
2766#[derive(Debug, Clone)]
2767pub struct ParagraphReflow {
2768    /// Starting byte offset of the paragraph in the original content
2769    pub start_byte: usize,
2770    /// Ending byte offset of the paragraph in the original content
2771    pub end_byte: usize,
2772    /// The reflowed text for this paragraph
2773    pub reflowed_text: String,
2774}
2775
2776/// A collected blockquote line used for style-preserving reflow.
2777///
2778/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
2779/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
2780/// rather than constructing the struct directly.
2781#[derive(Debug, Clone)]
2782pub struct BlockquoteLineData {
2783    /// Trimmed content without the `> ` prefix.
2784    pub(crate) content: String,
2785    /// Whether this line carries an explicit blockquote marker.
2786    pub(crate) is_explicit: bool,
2787    /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
2788    pub(crate) prefix: Option<String>,
2789}
2790
2791impl BlockquoteLineData {
2792    /// Create an explicit (marker-bearing) blockquote line.
2793    pub fn explicit(content: String, prefix: String) -> Self {
2794        Self {
2795            content,
2796            is_explicit: true,
2797            prefix: Some(prefix),
2798        }
2799    }
2800
2801    /// Create a lazy continuation line (no blockquote marker).
2802    pub fn lazy(content: String) -> Self {
2803        Self {
2804            content,
2805            is_explicit: false,
2806            prefix: None,
2807        }
2808    }
2809}
2810
2811/// Style for blockquote continuation lines after reflow.
2812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2813pub enum BlockquoteContinuationStyle {
2814    Explicit,
2815    Lazy,
2816}
2817
2818/// Determine the continuation style for a blockquote paragraph from its collected lines.
2819///
2820/// The first line is always explicit (it carries the marker), so only continuation
2821/// lines (index 1+) are counted. Ties resolve to `Explicit`.
2822///
2823/// When the slice has only one element (no continuation lines to inspect), both
2824/// counts are zero and the tie-breaking rule returns `Explicit`.
2825pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
2826    let mut explicit_count = 0usize;
2827    let mut lazy_count = 0usize;
2828
2829    for line in lines.iter().skip(1) {
2830        if line.is_explicit {
2831            explicit_count += 1;
2832        } else {
2833            lazy_count += 1;
2834        }
2835    }
2836
2837    if explicit_count > 0 && lazy_count == 0 {
2838        BlockquoteContinuationStyle::Explicit
2839    } else if lazy_count > 0 && explicit_count == 0 {
2840        BlockquoteContinuationStyle::Lazy
2841    } else if explicit_count >= lazy_count {
2842        BlockquoteContinuationStyle::Explicit
2843    } else {
2844        BlockquoteContinuationStyle::Lazy
2845    }
2846}
2847
2848/// Determine the dominant blockquote prefix for a paragraph.
2849///
2850/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
2851/// first appearance. Falls back to `fallback` when no explicit lines are present.
2852pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
2853    let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
2854
2855    for (idx, line) in lines.iter().enumerate() {
2856        let Some(prefix) = line.prefix.as_ref() else {
2857            continue;
2858        };
2859        counts
2860            .entry(prefix.clone())
2861            .and_modify(|entry| entry.0 += 1)
2862            .or_insert((1, idx));
2863    }
2864
2865    counts
2866        .into_iter()
2867        .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
2868            count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
2869        })
2870        .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
2871}
2872
2873/// Whether a reflowed blockquote content line must carry an explicit prefix.
2874///
2875/// Lines that would start a new block structure (headings, fences, lists, etc.)
2876/// cannot safely use lazy continuation syntax.
2877pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
2878    let trimmed = content_line.trim_start();
2879    trimmed.starts_with('>')
2880        || trimmed.starts_with('#')
2881        || trimmed.starts_with("```")
2882        || trimmed.starts_with("~~~")
2883        || is_unordered_list_marker(trimmed)
2884        || is_numbered_list_item(trimmed)
2885        || is_horizontal_rule(trimmed)
2886        || is_definition_list_item(trimmed)
2887        || (trimmed.starts_with('[') && trimmed.contains("]:"))
2888        || trimmed.starts_with(":::")
2889        || (trimmed.starts_with('<')
2890            && !trimmed.starts_with("<http")
2891            && !trimmed.starts_with("<https")
2892            && !trimmed.starts_with("<mailto:"))
2893}
2894
2895/// Reflow blockquote content lines and apply continuation style.
2896///
2897/// Segments separated by hard breaks are reflowed independently. The output lines
2898/// receive blockquote prefixes according to `continuation_style`: the first line and
2899/// any line that would start a new block structure always get an explicit prefix;
2900/// other lines follow the detected style.
2901///
2902/// Returns the styled, reflowed lines (without a trailing newline).
2903pub fn reflow_blockquote_content(
2904    lines: &[BlockquoteLineData],
2905    explicit_prefix: &str,
2906    continuation_style: BlockquoteContinuationStyle,
2907    options: &ReflowOptions,
2908) -> Vec<String> {
2909    let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
2910    let segments = split_into_segments_strs(&content_strs);
2911    let mut reflowed_content_lines: Vec<String> = Vec::new();
2912
2913    for segment in segments {
2914        let hard_break_type = segment.last().and_then(|&line| {
2915            let line = line.strip_suffix('\r').unwrap_or(line);
2916            if line.ends_with('\\') {
2917                Some("\\")
2918            } else if line.ends_with("  ") {
2919                Some("  ")
2920            } else {
2921                None
2922            }
2923        });
2924
2925        let pieces: Vec<&str> = segment
2926            .iter()
2927            .map(|&line| {
2928                if let Some(l) = line.strip_suffix('\\') {
2929                    l.trim_end()
2930                } else if let Some(l) = line.strip_suffix("  ") {
2931                    l.trim_end()
2932                } else {
2933                    line.trim_end()
2934                }
2935            })
2936            .collect();
2937
2938        let segment_text = pieces.join(" ");
2939        let segment_text = segment_text.trim();
2940        if segment_text.is_empty() {
2941            continue;
2942        }
2943
2944        let mut reflowed = reflow_line(segment_text, options);
2945        if let Some(break_marker) = hard_break_type
2946            && !reflowed.is_empty()
2947        {
2948            let last_idx = reflowed.len() - 1;
2949            if !has_hard_break(&reflowed[last_idx]) {
2950                reflowed[last_idx].push_str(break_marker);
2951            }
2952        }
2953        reflowed_content_lines.extend(reflowed);
2954    }
2955
2956    let mut styled_lines: Vec<String> = Vec::new();
2957    for (idx, line) in reflowed_content_lines.iter().enumerate() {
2958        let force_explicit = idx == 0
2959            || continuation_style == BlockquoteContinuationStyle::Explicit
2960            || should_force_explicit_blockquote_line(line);
2961        if force_explicit {
2962            styled_lines.push(format!("{explicit_prefix}{line}"));
2963        } else {
2964            styled_lines.push(line.clone());
2965        }
2966    }
2967
2968    styled_lines
2969}
2970
2971fn is_blockquote_content_boundary(content: &str) -> bool {
2972    let trimmed = content.trim();
2973    trimmed.is_empty()
2974        || is_block_boundary(trimmed)
2975        || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
2976        || trimmed.starts_with(":::")
2977        || crate::utils::is_template_directive_only(content)
2978        || is_standalone_attr_list(content)
2979        || is_snippet_block_delimiter(content)
2980}
2981
2982fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
2983    let mut segments = Vec::new();
2984    let mut current = Vec::new();
2985
2986    for &line in lines {
2987        current.push(line);
2988        if has_hard_break(line) {
2989            segments.push(current);
2990            current = Vec::new();
2991        }
2992    }
2993
2994    if !current.is_empty() {
2995        segments.push(current);
2996    }
2997
2998    segments
2999}
3000
3001fn reflow_blockquote_paragraph_at_line(
3002    content: &str,
3003    lines: &[&str],
3004    target_idx: usize,
3005    options: &ReflowOptions,
3006) -> Option<ParagraphReflow> {
3007    let mut anchor_idx = target_idx;
3008    let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
3009        parsed.nesting_level
3010    } else {
3011        let mut found = None;
3012        let mut idx = target_idx;
3013        loop {
3014            if lines[idx].trim().is_empty() {
3015                break;
3016            }
3017            if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
3018                found = Some((idx, parsed.nesting_level));
3019                break;
3020            }
3021            if idx == 0 {
3022                break;
3023            }
3024            idx -= 1;
3025        }
3026        let (idx, level) = found?;
3027        anchor_idx = idx;
3028        level
3029    };
3030
3031    // Expand backward to capture prior quote content at the same nesting level.
3032    let mut para_start = anchor_idx;
3033    while para_start > 0 {
3034        let prev_idx = para_start - 1;
3035        let prev_line = lines[prev_idx];
3036
3037        if prev_line.trim().is_empty() {
3038            break;
3039        }
3040
3041        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
3042            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3043                break;
3044            }
3045            para_start = prev_idx;
3046            continue;
3047        }
3048
3049        let prev_lazy = prev_line.trim_start();
3050        if is_blockquote_content_boundary(prev_lazy) {
3051            break;
3052        }
3053        para_start = prev_idx;
3054    }
3055
3056    // Lazy continuation cannot precede the first explicit marker.
3057    while para_start < lines.len() {
3058        let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
3059            para_start += 1;
3060            continue;
3061        };
3062        target_level = parsed.nesting_level;
3063        break;
3064    }
3065
3066    if para_start >= lines.len() || para_start > target_idx {
3067        return None;
3068    }
3069
3070    // Collect explicit lines at target level and lazy continuation lines.
3071    // Each entry is (original_line_idx, BlockquoteLineData).
3072    let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
3073    let mut idx = para_start;
3074    while idx < lines.len() {
3075        if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
3076            break;
3077        }
3078
3079        let line = lines[idx];
3080        if line.trim().is_empty() {
3081            break;
3082        }
3083
3084        if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
3085            if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
3086                break;
3087            }
3088            collected.push((
3089                idx,
3090                BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
3091            ));
3092            idx += 1;
3093            continue;
3094        }
3095
3096        let lazy_content = line.trim_start();
3097        if is_blockquote_content_boundary(lazy_content) {
3098            break;
3099        }
3100
3101        collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
3102        idx += 1;
3103    }
3104
3105    if collected.is_empty() {
3106        return None;
3107    }
3108
3109    let para_end = collected[collected.len() - 1].0;
3110    if target_idx < para_start || target_idx > para_end {
3111        return None;
3112    }
3113
3114    let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
3115
3116    let fallback_prefix = line_data
3117        .iter()
3118        .find_map(|d| d.prefix.clone())
3119        .unwrap_or_else(|| "> ".to_string());
3120    let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
3121    let continuation_style = blockquote_continuation_style(&line_data);
3122
3123    let adjusted_line_length = options
3124        .line_length
3125        .saturating_sub(display_len(&explicit_prefix, options.length_mode))
3126        .max(1);
3127
3128    let adjusted_options = ReflowOptions {
3129        line_length: adjusted_line_length,
3130        ..options.clone()
3131    };
3132
3133    let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
3134
3135    if styled_lines.is_empty() {
3136        return None;
3137    }
3138
3139    // Calculate byte offsets.
3140    let mut start_byte = 0;
3141    for line in lines.iter().take(para_start) {
3142        start_byte += line.len() + 1;
3143    }
3144
3145    let mut end_byte = start_byte;
3146    for line in lines.iter().take(para_end + 1).skip(para_start) {
3147        end_byte += line.len() + 1;
3148    }
3149
3150    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3151    if !includes_trailing_newline {
3152        end_byte -= 1;
3153    }
3154
3155    let reflowed_joined = styled_lines.join("\n");
3156    let reflowed_text = if includes_trailing_newline {
3157        if reflowed_joined.ends_with('\n') {
3158            reflowed_joined
3159        } else {
3160            format!("{reflowed_joined}\n")
3161        }
3162    } else if reflowed_joined.ends_with('\n') {
3163        reflowed_joined.trim_end_matches('\n').to_string()
3164    } else {
3165        reflowed_joined
3166    };
3167
3168    Some(ParagraphReflow {
3169        start_byte,
3170        end_byte,
3171        reflowed_text,
3172    })
3173}
3174
3175/// Reflow a single paragraph at the specified line number
3176///
3177/// This function finds the paragraph containing the given line number,
3178/// reflows it according to the specified line length, and returns
3179/// information about the paragraph location and its reflowed text.
3180///
3181/// # Arguments
3182///
3183/// * `content` - The full document content
3184/// * `line_number` - The 1-based line number within the paragraph to reflow
3185/// * `line_length` - The target line length for reflowing
3186///
3187/// # Returns
3188///
3189/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
3190/// or `None` if the line number is out of bounds or the content at that
3191/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
3192pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
3193    reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
3194}
3195
3196/// Reflow a paragraph at the given line with a specific length mode.
3197pub fn reflow_paragraph_at_line_with_mode(
3198    content: &str,
3199    line_number: usize,
3200    line_length: usize,
3201    length_mode: ReflowLengthMode,
3202) -> Option<ParagraphReflow> {
3203    let options = ReflowOptions {
3204        line_length,
3205        length_mode,
3206        ..Default::default()
3207    };
3208    reflow_paragraph_at_line_with_options(content, line_number, &options)
3209}
3210
3211/// Reflow a paragraph at the given line using the provided options.
3212///
3213/// This is the canonical implementation used by both the rule's fix mode and the
3214/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
3215/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
3216///
3217/// # Returns
3218///
3219/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
3220/// if the line is out of bounds or sits inside a non-reflow-able construct.
3221pub fn reflow_paragraph_at_line_with_options(
3222    content: &str,
3223    line_number: usize,
3224    options: &ReflowOptions,
3225) -> Option<ParagraphReflow> {
3226    if line_number == 0 {
3227        return None;
3228    }
3229
3230    let lines: Vec<&str> = content.lines().collect();
3231
3232    // Check if line number is valid (1-based)
3233    if line_number > lines.len() {
3234        return None;
3235    }
3236
3237    let target_idx = line_number - 1; // Convert to 0-based
3238    let target_line = lines[target_idx];
3239    let trimmed = target_line.trim();
3240
3241    // Handle blockquote paragraphs (including lazy continuation lines) with
3242    // style-preserving output.
3243    if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
3244        return Some(blockquote_reflow);
3245    }
3246
3247    // Don't reflow special blocks
3248    if is_paragraph_boundary(trimmed, target_line) {
3249        return None;
3250    }
3251
3252    // Find paragraph start - scan backward until blank line or special block
3253    let mut para_start = target_idx;
3254    while para_start > 0 {
3255        let prev_idx = para_start - 1;
3256        let prev_line = lines[prev_idx];
3257        let prev_trimmed = prev_line.trim();
3258
3259        // Stop at blank line or special blocks
3260        if is_paragraph_boundary(prev_trimmed, prev_line) {
3261            break;
3262        }
3263
3264        para_start = prev_idx;
3265    }
3266
3267    // Find paragraph end - scan forward until blank line or special block
3268    let mut para_end = target_idx;
3269    while para_end + 1 < lines.len() {
3270        let next_idx = para_end + 1;
3271        let next_line = lines[next_idx];
3272        let next_trimmed = next_line.trim();
3273
3274        // Stop at blank line or special blocks
3275        if is_paragraph_boundary(next_trimmed, next_line) {
3276            break;
3277        }
3278
3279        para_end = next_idx;
3280    }
3281
3282    // Extract paragraph lines
3283    let paragraph_lines = &lines[para_start..=para_end];
3284
3285    // Calculate byte offsets
3286    let mut start_byte = 0;
3287    for line in lines.iter().take(para_start) {
3288        start_byte += line.len() + 1; // +1 for newline
3289    }
3290
3291    let mut end_byte = start_byte;
3292    for line in paragraph_lines {
3293        end_byte += line.len() + 1; // +1 for newline
3294    }
3295
3296    // Track whether the byte range includes a trailing newline
3297    // (it doesn't if this is the last line and the file doesn't end with newline)
3298    let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
3299
3300    // Adjust end_byte if the last line doesn't have a newline
3301    if !includes_trailing_newline {
3302        end_byte -= 1;
3303    }
3304
3305    // Join paragraph lines and reflow
3306    let paragraph_text = paragraph_lines.join("\n");
3307
3308    // Reflow the paragraph using reflow_markdown to handle it properly
3309    let reflowed = reflow_markdown(&paragraph_text, options);
3310
3311    // Ensure reflowed text matches whether the byte range includes a trailing newline
3312    // This is critical: if the range includes a newline, the replacement must too,
3313    // otherwise the next line will get appended to the reflowed paragraph
3314    let reflowed_text = if includes_trailing_newline {
3315        // Range includes newline - ensure reflowed text has one
3316        if reflowed.ends_with('\n') {
3317            reflowed
3318        } else {
3319            format!("{reflowed}\n")
3320        }
3321    } else {
3322        // Range doesn't include newline - ensure reflowed text doesn't have one
3323        if reflowed.ends_with('\n') {
3324            reflowed.trim_end_matches('\n').to_string()
3325        } else {
3326            reflowed
3327        }
3328    };
3329
3330    Some(ParagraphReflow {
3331        start_byte,
3332        end_byte,
3333        reflowed_text,
3334    })
3335}
3336
3337#[cfg(test)]
3338mod tests {
3339    use super::*;
3340
3341    #[test]
3342    fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
3343        // A single line of thousands of words once drove `cascade_split_line`
3344        // into deep recursion (stack overflow / hang). The iterative version
3345        // must complete and split it into many lines that each fit the width and
3346        // that together preserve every word. The test finishing at all is the
3347        // core assertion (no stack overflow); the content checks guard behavior.
3348        let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
3349        let line = words.join(" ");
3350
3351        let out = cascade_split_line(&line, 80, &None, ReflowLengthMode::Chars, false, false, None);
3352
3353        assert!(out.len() > 1, "a very long line should split into many lines");
3354        for segment in &out {
3355            assert!(
3356                display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
3357                "each wrapped line should fit the width (or be a single unbreakable token)"
3358            );
3359        }
3360        // Every original word survives, in order.
3361        let rejoined = out.join(" ");
3362        let original_words: Vec<&str> = line.split(' ').collect();
3363        let result_words: Vec<&str> = rejoined.split_whitespace().collect();
3364        assert_eq!(original_words, result_words, "reflow must preserve all words in order");
3365    }
3366
3367    /// Unit test for private helper function text_ends_with_abbreviation()
3368    ///
3369    /// This test stays inline because it tests a private function.
3370    /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
3371    #[test]
3372    fn test_helper_function_text_ends_with_abbreviation() {
3373        // Test the helper function directly
3374        let abbreviations = get_abbreviations(&None);
3375
3376        // True cases - built-in abbreviations (titles and i.e./e.g.)
3377        assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
3378        assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
3379        assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
3380        assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
3381        assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
3382        assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
3383        assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
3384        assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
3385
3386        // False cases - NOT in built-in list (etc doesn't always have period)
3387        assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
3388        assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
3389        assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
3390        assert!(!text_ends_with_abbreviation("items.", &abbreviations));
3391        assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
3392        assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
3393        assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
3394        assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
3395        assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
3396        assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
3397    }
3398
3399    #[test]
3400    fn test_is_unordered_list_marker() {
3401        // Valid unordered list markers
3402        assert!(is_unordered_list_marker("- item"));
3403        assert!(is_unordered_list_marker("* item"));
3404        assert!(is_unordered_list_marker("+ item"));
3405        assert!(is_unordered_list_marker("-")); // lone marker
3406        assert!(is_unordered_list_marker("*"));
3407        assert!(is_unordered_list_marker("+"));
3408
3409        // Not list markers
3410        assert!(!is_unordered_list_marker("---")); // horizontal rule
3411        assert!(!is_unordered_list_marker("***")); // horizontal rule
3412        assert!(!is_unordered_list_marker("- - -")); // horizontal rule
3413        assert!(!is_unordered_list_marker("* * *")); // horizontal rule
3414        assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
3415        assert!(!is_unordered_list_marker("-word")); // no space after marker
3416        assert!(!is_unordered_list_marker("")); // empty
3417        assert!(!is_unordered_list_marker("text")); // plain text
3418        assert!(!is_unordered_list_marker("# heading")); // heading
3419    }
3420
3421    #[test]
3422    fn test_is_block_boundary() {
3423        // Block boundaries
3424        assert!(is_block_boundary("")); // empty line
3425        assert!(is_block_boundary("# Heading")); // ATX heading
3426        assert!(is_block_boundary("## Level 2")); // ATX heading
3427        assert!(is_block_boundary("```rust")); // code fence
3428        assert!(is_block_boundary("~~~")); // tilde code fence
3429        assert!(is_block_boundary("> quote")); // blockquote
3430        assert!(is_block_boundary("| cell |")); // table
3431        assert!(is_block_boundary("[link]: http://example.com")); // reference def
3432        assert!(is_block_boundary("---")); // horizontal rule
3433        assert!(is_block_boundary("***")); // horizontal rule
3434        assert!(is_block_boundary("- item")); // unordered list
3435        assert!(is_block_boundary("* item")); // unordered list
3436        assert!(is_block_boundary("+ item")); // unordered list
3437        assert!(is_block_boundary("1. item")); // ordered list
3438        assert!(is_block_boundary("10. item")); // ordered list
3439        assert!(is_block_boundary(": definition")); // definition list
3440        assert!(is_block_boundary(":::")); // div marker
3441        assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
3442
3443        // NOT block boundaries (paragraph continuation)
3444        assert!(!is_block_boundary("regular text"));
3445        assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
3446        assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
3447        assert!(!is_block_boundary("some words here"));
3448    }
3449
3450    #[test]
3451    fn test_definition_list_boundary_in_single_line_paragraph() {
3452        // Verifies that a definition list item after a single-line paragraph
3453        // is treated as a block boundary, not merged into the paragraph
3454        let options = ReflowOptions {
3455            line_length: 80,
3456            ..Default::default()
3457        };
3458        let input = "Term\n: Definition of the term";
3459        let result = reflow_markdown(input, &options);
3460        // The definition list marker should remain on its own line
3461        assert!(
3462            result.contains(": Definition"),
3463            "Definition list item should not be merged into previous line. Got: {result:?}"
3464        );
3465        let lines: Vec<&str> = result.lines().collect();
3466        assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
3467        assert_eq!(lines[0], "Term");
3468        assert_eq!(lines[1], ": Definition of the term");
3469    }
3470
3471    #[test]
3472    fn test_is_paragraph_boundary() {
3473        // Core block boundary checks are inherited
3474        assert!(is_paragraph_boundary("# Heading", "# Heading"));
3475        assert!(is_paragraph_boundary("- item", "- item"));
3476        assert!(is_paragraph_boundary(":::", ":::"));
3477        assert!(is_paragraph_boundary(": definition", ": definition"));
3478
3479        // Indented code blocks (≥4 spaces or tab)
3480        assert!(is_paragraph_boundary("code", "    code"));
3481        assert!(is_paragraph_boundary("code", "\tcode"));
3482
3483        // Table rows via is_potential_table_row
3484        assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
3485        assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
3486
3487        // Not paragraph boundaries
3488        assert!(!is_paragraph_boundary("regular text", "regular text"));
3489        assert!(!is_paragraph_boundary("text", "  text")); // 2-space indent is not code
3490    }
3491
3492    #[test]
3493    fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
3494        // Verifies that div markers (:::) are treated as paragraph boundaries
3495        // in reflow_paragraph_at_line, preventing reflow across div boundaries
3496        let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
3497        // Line 3 is the div marker — should not be reflowed
3498        let result = reflow_paragraph_at_line(content, 3, 80);
3499        assert!(result.is_none(), "Div marker line should not be reflowed");
3500    }
3501}