Skip to main content

rumdl_lib/utils/
text_reflow.rs

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