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