Skip to main content

snapper_fmt/
reflow.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use crate::config::CodeLang;
5use crate::format::Format;
6use crate::parser::{Region, RegionOrigin, SpannedRegion};
7use crate::sentence::SentenceSplitter;
8use crate::sentence::unicode::atomic_inline_spans;
9
10/// Configuration for the reflow engine.
11pub struct ReflowConfig<'a> {
12    /// Maximum line width. 0 means unlimited.
13    pub max_width: usize,
14    /// Per-language code-block configuration (borrowed from `FormatConfig`).
15    pub code: Option<&'a HashMap<String, CodeLang>>,
16    /// When `true`, the per-language `formatter` runs after comment reflow.
17    pub format_code: bool,
18    /// Prefer soft breaks after independent-clause punctuation (`,`, `;`,
19    /// `:`, em dash, `--`). When `max_width` is 0, every such mark that is
20    /// already followed by whitespace starts a new line. When `max_width`
21    /// is greater than 0, overflowing sentences prefer those marks.
22    pub clause_breaks: bool,
23    /// Markup format of the document being reflowed. Wrap-created line
24    /// starts that the format parser would read as a new block are
25    /// escaped (Markdown) or the cut is skipped (Org and the rest).
26    pub format: Format,
27}
28
29impl Default for ReflowConfig<'_> {
30    fn default() -> Self {
31        Self {
32            max_width: 0,
33            code: None,
34            format_code: false,
35            clause_breaks: false,
36            format: Format::Plaintext,
37        }
38    }
39}
40
41/// Minimum region count before parallelizing reflow (large multi-MB org/md files).
42#[cfg(feature = "cli")]
43const PARALLEL_REGION_THRESHOLD: usize = 32;
44
45/// Reflow a sequence of regions, applying sentence breaks to Prose regions.
46///
47/// With the `cli` feature, files that parse into many regions (typical large
48/// Org/Markdown trees) reflow independent regions in parallel via rayon, then
49/// concatenate in order.
50pub fn reflow(
51    regions: &[Region],
52    splitter: &dyn SentenceSplitter,
53    config: &ReflowConfig,
54) -> String {
55    #[cfg(feature = "cli")]
56    {
57        if regions.len() >= PARALLEL_REGION_THRESHOLD {
58            return reflow_parallel(regions, splitter, config);
59        }
60    }
61    reflow_sequential(regions, splitter, config)
62}
63
64/// Why splice could not proceed. Callers fail closed (original document
65/// or an error) instead of skipping the bad range.
66#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67#[error("{0}")]
68pub struct SpliceError(pub String);
69
70/// Reflow using parser-recorded byte ranges: non-prose is copied from
71/// `source`, and only prose (plus rewritten comment spans inside code)
72/// is rewritten. Missing origins or invalid spans are errors.
73pub fn reflow_spanned(
74    source: &str,
75    spanned: &[SpannedRegion],
76    splitter: &dyn SentenceSplitter,
77    config: &ReflowConfig,
78) -> Result<String, SpliceError> {
79    if spanned.is_empty() {
80        return Ok(String::new());
81    }
82    if let Some((i, _)) = spanned.iter().enumerate().find(|(_, s)| s.origin.is_none()) {
83        return Err(SpliceError(format!("region {i} has no source origin")));
84    }
85    splice(source, spanned, splitter, config)
86}
87
88fn splice(
89    source: &str,
90    spanned: &[SpannedRegion],
91    splitter: &dyn SentenceSplitter,
92    config: &ReflowConfig,
93) -> Result<String, SpliceError> {
94    let regions: Vec<Region> = spanned.iter().map(|s| s.region.clone()).collect();
95    let mut rewrites: Vec<(usize, usize, String)> = Vec::new();
96    for (idx, sr) in spanned.iter().enumerate() {
97        let origin = sr
98            .origin
99            .as_ref()
100            .ok_or_else(|| SpliceError(format!("region {idx} has no source origin")))?;
101        match (&sr.region, origin) {
102            (Region::Prose(text), RegionOrigin::Whole(span)) => {
103                let replacement = reflow_prose(text, idx, &regions, splitter, config);
104                rewrites.push((span.start, span.end, replacement));
105            }
106            (
107                Region::Code { lang, body, .. },
108                RegionOrigin::Code {
109                    body: body_span, ..
110                },
111            ) => {
112                let code_cfg = lang
113                    .as_deref()
114                    .and_then(|l| config.code.and_then(|m| m.get(l)));
115                if let Some(cfg) = code_cfg {
116                    let reflowed = crate::code_block::reflow_code_body(
117                        lang.as_deref().unwrap_or(""),
118                        body,
119                        cfg,
120                        splitter,
121                        config.format_code,
122                    );
123                    if reflowed != *body {
124                        rewrites.push((body_span.start, body_span.end, reflowed));
125                    }
126                }
127            }
128            _ => {}
129        }
130    }
131    rewrites.sort_by_key(|(start, _, _)| *start);
132    let mut out = String::with_capacity(source.len());
133    let mut cursor = 0usize;
134    for (start, end, repl) in rewrites {
135        if start < cursor || end > source.len() || start > end || source.get(start..end).is_none() {
136            return Err(SpliceError(format!(
137                "invalid splice span {start}..{end} (cursor={cursor}, len={})",
138                source.len()
139            )));
140        }
141        out.push_str(&source[cursor..start]);
142        out.push_str(&repl);
143        cursor = end;
144    }
145    out.push_str(&source[cursor..]);
146    Ok(out)
147}
148
149fn reflow_sequential(
150    regions: &[Region],
151    splitter: &dyn SentenceSplitter,
152    config: &ReflowConfig,
153) -> String {
154    let mut output = String::new();
155    for (idx, region) in regions.iter().enumerate() {
156        output.push_str(&reflow_one(region, idx, regions, splitter, config));
157    }
158    output
159}
160
161#[cfg(feature = "cli")]
162fn reflow_parallel(
163    regions: &[Region],
164    splitter: &dyn SentenceSplitter,
165    config: &ReflowConfig,
166) -> String {
167    use rayon::prelude::*;
168    // Indexed parallel map preserves order on collect.
169    let parts: Vec<String> = regions
170        .par_iter()
171        .enumerate()
172        .map(|(idx, region)| reflow_one(region, idx, regions, splitter, config))
173        .collect();
174    let mut output = String::new();
175    for p in parts {
176        output.push_str(&p);
177    }
178    output
179}
180
181fn reflow_one(
182    region: &Region,
183    idx: usize,
184    regions: &[Region],
185    splitter: &dyn SentenceSplitter,
186    config: &ReflowConfig,
187) -> String {
188    let mut output = String::new();
189    match region {
190        Region::Structure(s) => output.push_str(s),
191        Region::BlankLines(s) => output.push_str(s),
192        Region::Code {
193            lang,
194            header,
195            body,
196            footer,
197        } => {
198            output.push_str(header);
199            let code_cfg = lang
200                .as_deref()
201                .and_then(|l| config.code.and_then(|m| m.get(l)));
202            let reflowed = if let Some(cfg) = code_cfg {
203                crate::code_block::reflow_code_body(
204                    lang.as_deref().unwrap_or(""),
205                    body,
206                    cfg,
207                    splitter,
208                    config.format_code,
209                )
210            } else {
211                body.clone()
212            };
213            output.push_str(&reflowed);
214            output.push_str(footer);
215        }
216        Region::Prose(text) => {
217            output.push_str(&reflow_prose(text, idx, regions, splitter, config));
218        }
219    }
220    output
221}
222
223fn reflow_prose(
224    text: &str,
225    idx: usize,
226    regions: &[Region],
227    splitter: &dyn SentenceSplitter,
228    config: &ReflowConfig,
229) -> String {
230    let mut output = String::new();
231    let hang = match idx.checked_sub(1).and_then(|i| regions.get(i)) {
232        Some(Region::Structure(s)) => hanging_prefix(s),
233        _ => String::new(),
234    };
235    let hanging = hang.chars().count();
236    let sentences = splitter.split(text);
237    let nsent = sentences.len();
238    for (i, sentence) in sentences.iter().enumerate() {
239        if config.max_width > 0 || config.clause_breaks {
240            let layout = WrapLayout {
241                initial_column: if i == 0 { hanging } else { 0 },
242                first_indent: if i == 0 { "" } else { hang.as_str() },
243                subsequent_indent: hang.as_str(),
244            };
245            let wrapped = wrap_prose(
246                sentence,
247                config.max_width,
248                config.clause_breaks,
249                config.format,
250                layout,
251            );
252            output.push_str(&wrapped);
253        } else {
254            if hanging > 0 && i > 0 {
255                output.push_str(&hang);
256            }
257            output.push_str(sentence);
258        }
259        if i + 1 < nsent {
260            output.push('\n');
261        }
262    }
263    if !sentences.is_empty() {
264        // Splitter trims; keep a mid-line TeX ` % comment` space (not newlines).
265        if text.ends_with([' ', '\t']) && !output.ends_with(char::is_whitespace) {
266            let trail: String = text
267                .chars()
268                .rev()
269                .take_while(|c| *c == ' ' || *c == '\t')
270                .collect::<String>()
271                .chars()
272                .rev()
273                .collect();
274            output.push_str(&trail);
275        }
276        // No forced paragraph break before inline islands (math/code) or
277        // tight punctuation structures — those continue the same line.
278        let suppress = match regions.get(idx + 1) {
279            Some(Region::Structure(s)) if suppress_prose_trailing_newline(s) => true,
280            Some(Region::Structure(s))
281                if s.trim_start().starts_with('%') && text.ends_with([' ', '\t']) =>
282            {
283                true
284            }
285            _ => false,
286        };
287        if !suppress {
288            output.push('\n');
289        }
290    }
291    output
292}
293
294/// True when `word` ends with independent-clause punctuation (sembr rule 5),
295/// ignoring trailing closing quotes and brackets. Words come from whitespace
296/// splitting, so a match always marks a lossless break site: the punctuation
297/// is followed by real whitespace in the source.
298fn ends_with_clause_punct(word: &str) -> bool {
299    let core = word.trim_end_matches(['"', '\'', ')', ']', '}']);
300    core.ends_with(',')
301        || core.ends_with(';')
302        || core.ends_with(':')
303        || core.ends_with('\u{2014}') // em dash —
304        || core.ends_with("--")
305}
306
307/// Wrap `sentence` under `max_width`, preferring breaks after clause
308/// punctuation (sembr rule 5). When `max_width` is 0, every independent
309/// clause is placed on its own line. When `max_width` is greater than 0,
310/// a sentence that already fits stays on one line and overflow prefers
311/// a clause boundary. Breaks only ever land at whitespace outside atomic
312/// tokens, so links, inline code, `$math$`, and tokens like `1,000`,
313/// `10:30`, URLs, and `--flags` are never split apart.
314pub fn wrap_with_clause_breaks(sentence: &str, max_width: usize) -> String {
315    wrap_prose(
316        sentence,
317        max_width,
318        true,
319        Format::Plaintext,
320        WrapLayout::default(),
321    )
322}
323
324#[derive(Default)]
325struct WrapLayout<'a> {
326    /// Columns already occupied on the first emitted line (list marker).
327    initial_column: usize,
328    /// Prefix for the first emitted line (subsequent sentences of a hang).
329    first_indent: &'a str,
330    /// Prefix for wrap-created lines. Interrupt is tested on the content
331    /// after this indent; column-0 escape is the no-hang fallback.
332    subsequent_indent: &'a str,
333}
334
335fn wrap_prose(
336    sentence: &str,
337    max_width: usize,
338    clause_breaks: bool,
339    format: Format,
340    layout: WrapLayout<'_>,
341) -> String {
342    if max_width == 0 {
343        if !clause_breaks {
344            return sentence.to_string();
345        }
346        return break_at_clause_punct(sentence, format, layout).join("\n");
347    }
348    wrap_atomic_words(sentence, max_width, clause_breaks, format, layout).join("\n")
349}
350
351/// Whitespace words with links, images, inline code, autolinks, math, and
352/// Org `[[...]]` kept whole (internal spaces do not split). Adjacent
353/// non-space text glues to a token so `foo[a](b)bar` is one word.
354fn split_atomic_words(text: &str) -> Vec<&str> {
355    let spans = atomic_inline_spans(text);
356    let mut words = Vec::new();
357    let mut buf_start: Option<usize> = None;
358    let mut buf_end = 0usize;
359    let mut pos = 0usize;
360    let mut span_i = 0usize;
361
362    while pos < text.len() {
363        if span_i < spans.len() && pos >= spans[span_i].1 {
364            span_i += 1;
365            continue;
366        }
367        if span_i < spans.len() && pos == spans[span_i].0 {
368            let end = spans[span_i].1;
369            if buf_start.is_none() {
370                buf_start = Some(pos);
371            }
372            buf_end = end;
373            pos = end;
374            span_i += 1;
375            continue;
376        }
377        let rest_end = if span_i < spans.len() {
378            spans[span_i].0
379        } else {
380            text.len()
381        };
382        if pos < rest_end {
383            let gap = &text[pos..rest_end];
384            for (i, ch) in gap.char_indices() {
385                if ch.is_whitespace() {
386                    if let Some(start) = buf_start.take() {
387                        words.push(&text[start..buf_end]);
388                    }
389                } else {
390                    let abs = pos + i;
391                    if buf_start.is_none() {
392                        buf_start = Some(abs);
393                    }
394                    buf_end = abs + ch.len_utf8();
395                }
396            }
397        }
398        pos = rest_end;
399    }
400    if let Some(start) = buf_start {
401        words.push(&text[start..buf_end]);
402    }
403    words
404}
405
406fn is_ordered_list_marker(word: &str) -> bool {
407    let bytes = word.as_bytes();
408    if bytes.len() < 2 {
409        return false;
410    }
411    let delim = *bytes.last().unwrap();
412    if delim != b'.' && delim != b')' {
413        return false;
414    }
415    bytes[..bytes.len() - 1].iter().all(|b| b.is_ascii_digit())
416}
417
418fn ordered_list_start(text: &str) -> bool {
419    let bytes = text.as_bytes();
420    let mut i = 0;
421    while i < bytes.len() && bytes[i].is_ascii_digit() {
422        i += 1;
423    }
424    if i == 0 {
425        return false;
426    }
427    matches!(bytes.get(i), Some(b'.') | Some(b')')) && matches!(bytes.get(i + 1), Some(b' ') | None)
428}
429
430fn thematic_or_setext_token(text: &str) -> bool {
431    let first = text.split_whitespace().next().unwrap_or("");
432    if first.len() < 3 {
433        return false;
434    }
435    let b = first.as_bytes()[0];
436    matches!(b, b'-' | b'=' | b'*' | b'_') && first.bytes().all(|c| c == b)
437}
438
439fn atx_heading_start(text: &str) -> bool {
440    let n = text.bytes().take_while(|&b| b == b'#').count();
441    (1..=6).contains(&n) && (text.len() == n || text.as_bytes()[n] == b' ')
442}
443
444fn md_list_start(text: &str) -> bool {
445    text.starts_with("- ")
446        || text.starts_with("* ")
447        || text.starts_with("+ ")
448        || ordered_list_start(text)
449}
450
451fn md_link_ref_def(text: &str) -> bool {
452    text.starts_with('[') && text.contains("]:")
453}
454
455/// CommonMark autolink after a leading `<`: scheme `:` (e.g. `https:`) or
456/// `local@host`. The inner run stops at `>` or whitespace.
457fn md_autolink_after_lt(rest: &str) -> bool {
458    let inner_end = rest
459        .find(|c: char| c == '>' || c.is_whitespace())
460        .unwrap_or(rest.len());
461    let inner = &rest[..inner_end];
462    if inner.is_empty() {
463        return false;
464    }
465    if let Some(colon) = inner.find(':') {
466        let scheme = &inner[..colon];
467        return !scheme.is_empty()
468            && scheme.as_bytes()[0].is_ascii_alphabetic()
469            && scheme
470                .bytes()
471                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'-'));
472    }
473    if let Some(at) = inner.find('@') {
474        let local = &inner[..at];
475        let host = &inner[at + 1..];
476        return !local.is_empty() && !host.is_empty() && !host.contains('@');
477    }
478    false
479}
480
481fn md_html_opener(text: &str) -> bool {
482    let Some(rest) = text.strip_prefix('<') else {
483        return false;
484    };
485    // Autolinks are inlines. Escaping `<https://...>` or `<user@host>`
486    // as an HTML block opener injects `\` and kills the autolink.
487    if md_autolink_after_lt(rest) {
488        return false;
489    }
490    rest.starts_with('!')
491        || rest.starts_with('?')
492        || rest.starts_with('/')
493        || rest.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
494}
495
496/// True when a wrap-created backslash on `word` would break an inline
497/// token (autolink, link, image, code, math, Org link). Prefer skip-cut.
498fn escape_would_corrupt_inline(word: &str) -> bool {
499    if let Some(rest) = word.strip_prefix('<') {
500        if md_autolink_after_lt(rest) {
501            return true;
502        }
503    }
504    (word.starts_with('[') && word.contains("]("))
505        || word.starts_with("![")
506        || word.starts_with('`')
507        || (word.starts_with('$') && word.ends_with('$') && word.len() >= 2)
508        || word.starts_with("[[")
509}
510
511/// True when `line` at column 0 is a new block in `format`'s grammar.
512/// Already-escaped Markdown (`\-`, `\[ref]:`) does not match. A leading
513/// `\` is not an escape in LaTeX (`\begin`, `\section`).
514fn line_opens_block(format: Format, line: &str) -> bool {
515    match format {
516        Format::Plaintext => false,
517        Format::Markdown => md_opens_block(line),
518        Format::Org => org_opens_block(line),
519        Format::Latex => latex_opens_block(line),
520        Format::Rst => rst_opens_block(line),
521    }
522}
523
524fn md_opens_block(line: &str) -> bool {
525    let t = line.trim_start();
526    if t.starts_with("```") || t.starts_with("~~~") {
527        return true;
528    }
529    if thematic_or_setext_token(t) {
530        return true;
531    }
532    if t.starts_with('>') {
533        return true;
534    }
535    if atx_heading_start(t) {
536        return true;
537    }
538    if md_list_start(t) {
539        return true;
540    }
541    if md_link_ref_def(t) {
542        return true;
543    }
544    if md_html_opener(t) {
545        return true;
546    }
547    false
548}
549
550fn org_opens_block(line: &str) -> bool {
551    let t = line.trim_start();
552    if t.starts_with('|') {
553        return true;
554    }
555    if t.starts_with('#') {
556        return true;
557    }
558    let stars = t.bytes().take_while(|&b| b == b'*').count();
559    if stars > 0 {
560        return t.len() == stars || matches!(t.as_bytes()[stars], b' ' | b'\t');
561    }
562    if t.starts_with("- ") || t.starts_with("+ ") {
563        return true;
564    }
565    ordered_list_start(t)
566}
567
568fn latex_opens_block(line: &str) -> bool {
569    let t = line.trim_start();
570    if t.starts_with('%') {
571        return true;
572    }
573    if t.starts_with("\\begin{") || t.starts_with("\\end{") || t.starts_with("\\[") {
574        return true;
575    }
576    const CMDS: &[&str] = &[
577        "\\part",
578        "\\chapter",
579        "\\section",
580        "\\subsection",
581        "\\subsubsection",
582        "\\paragraph",
583        "\\subparagraph",
584    ];
585    for cmd in CMDS {
586        if let Some(after) = t.strip_prefix(cmd) {
587            if after.is_empty()
588                || after.starts_with('{')
589                || after.starts_with('*')
590                || after.starts_with(' ')
591            {
592                return true;
593            }
594        }
595    }
596    false
597}
598
599fn rst_opens_block(line: &str) -> bool {
600    let t = line.trim_start();
601    if t == ".." || t.starts_with(".. ") || t.starts_with("..\t") {
602        return true;
603    }
604    if t.starts_with("- ") || t.starts_with("* ") || t.starts_with("+ ") {
605        return true;
606    }
607    if t.starts_with(':') && t[1..].contains(':') {
608        return true;
609    }
610    ordered_list_start(t)
611}
612
613/// Markdown backslash-escape for the first word of a wrap-created line.
614/// Already-escaped words are left alone so a second pass does not
615/// accumulate backslashes. Gated on Markdown only.
616fn escape_md_first_word(word: &str) -> String {
617    if word.starts_with('\\') {
618        return word.to_string();
619    }
620    if is_ordered_list_marker(word) {
621        let (digits, delim) = word.split_at(word.len() - 1);
622        return format!("{digits}\\{delim}");
623    }
624    let mut chars = word.chars();
625    let Some(first) = chars.next() else {
626        return String::new();
627    };
628    format!("\\{first}{}", chars.as_str())
629}
630
631fn displayed_first_word<'a>(
632    word: &'a str,
633    may_escape: bool,
634    rest: &str,
635    format: Format,
636) -> Cow<'a, str> {
637    if may_escape && format == Format::Markdown && line_opens_block(format, rest) {
638        Cow::Owned(escape_md_first_word(word))
639    } else {
640        Cow::Borrowed(word)
641    }
642}
643
644/// Skip a cut that would make the next line open a new block. Markdown
645/// usually escapes instead; skip-cut still wins when `\` would corrupt
646/// an inline token (autolink, link, code, math).
647fn skip_block_opening_cut(
648    words: &[&str],
649    start: usize,
650    mut break_at: usize,
651    format: Format,
652) -> usize {
653    while break_at < words.len() && break_at > start {
654        let candidate = words[break_at..].join(" ");
655        if !line_opens_block(format, &candidate) {
656            break;
657        }
658        if format == Format::Markdown && !escape_would_corrupt_inline(words[break_at]) {
659            break;
660        }
661        break_at += 1;
662    }
663    break_at
664}
665
666fn emit_wrapped_line(
667    words: &[&str],
668    start: usize,
669    break_at: usize,
670    indent: &str,
671    may_escape: bool,
672    format: Format,
673) -> String {
674    let mut line = indent.to_string();
675    let rest = words[start..break_at].join(" ");
676    for (i, word) in words[start..break_at].iter().enumerate() {
677        if i > 0 {
678            line.push(' ');
679        }
680        if i == 0 {
681            line.push_str(&displayed_first_word(word, may_escape, &rest, format));
682        } else {
683            line.push_str(word);
684        }
685    }
686    line
687}
688
689/// Insert a newline after every independent-clause mark that is already
690/// followed by whitespace. A sentence with no such mark stays one line.
691/// Hang and interrupt handling match the `max_width` wrap path.
692fn break_at_clause_punct(text: &str, format: Format, layout: WrapLayout<'_>) -> Vec<String> {
693    let words = split_atomic_words(text);
694    if words.is_empty() {
695        return Vec::new();
696    }
697    let mut lines = Vec::new();
698    let mut start = 0;
699    while start < words.len() {
700        let first = start == 0;
701        let indent = if first {
702            layout.first_indent
703        } else {
704            layout.subsequent_indent
705        };
706        // First line of the first sentence (marker already emitted) is not
707        // wrap-created. Later sentences and wrap-created lines may escape.
708        let may_escape = format == Format::Markdown && !(first && layout.first_indent.is_empty());
709
710        let last_breakable = words.len().saturating_sub(1);
711        let mut break_at = words[start..last_breakable]
712            .iter()
713            .position(|w| ends_with_clause_punct(w))
714            .map_or(words.len(), |i| start + i + 1);
715        break_at = skip_block_opening_cut(&words, start, break_at, format);
716        lines.push(emit_wrapped_line(
717            &words, start, break_at, indent, may_escape, format,
718        ));
719        start = break_at;
720    }
721    lines
722}
723
724/// Greedy wrap over atomic words. Forced breaks prefer the last clause
725/// punctuation on the line. A wrap that would start a new block is
726/// escaped in Markdown or skipped (loop) in other formats. The first
727/// line of a list item is not escaped. Interrupt is tested on content
728/// after hanging indent.
729fn wrap_atomic_words(
730    text: &str,
731    max_width: usize,
732    prefer_clause: bool,
733    format: Format,
734    layout: WrapLayout<'_>,
735) -> Vec<String> {
736    let words = split_atomic_words(text);
737    if words.is_empty() {
738        return Vec::new();
739    }
740    let mut lines = Vec::new();
741    let mut start = 0;
742    while start < words.len() {
743        let first = start == 0;
744        let indent = if first {
745            layout.first_indent
746        } else {
747            layout.subsequent_indent
748        };
749        let prefix_width = if first {
750            layout.initial_column + layout.first_indent.chars().count()
751        } else {
752            layout.subsequent_indent.chars().count()
753        };
754        // First line of the first sentence (marker already emitted) is not
755        // wrap-created. Later sentences and wrap-created lines may escape.
756        let may_escape = format == Format::Markdown && !(first && layout.first_indent.is_empty());
757
758        let mut end = start;
759        let mut line_len = prefix_width;
760        while end < words.len() {
761            let rest = words[end..].join(" ");
762            let displayed =
763                displayed_first_word(words[end], may_escape && end == start, &rest, format);
764            let wlen = displayed.chars().count();
765            let next_len = if end == start {
766                line_len + wlen
767            } else {
768                line_len + 1 + wlen
769            };
770            if next_len > max_width && end > start {
771                break;
772            }
773            line_len = next_len;
774            end += 1;
775            if end == start + 1 && line_len > max_width {
776                break;
777            }
778        }
779        let mut break_at = end;
780        if prefer_clause && end < words.len() {
781            for j in (start..end).rev() {
782                if ends_with_clause_punct(words[j]) {
783                    break_at = j + 1;
784                    break;
785                }
786            }
787        }
788        break_at = skip_block_opening_cut(&words, start, break_at, format);
789        lines.push(emit_wrapped_line(
790            &words, start, break_at, indent, may_escape, format,
791        ));
792        start = break_at;
793    }
794    lines
795}
796
797/// True when `s` is a list or quote marker that continuation lines hang from.
798pub(crate) fn is_hanging_marker(s: &str) -> bool {
799    !hanging_prefix(s).is_empty()
800}
801
802/// Prefix emitted on continuation lines after a list or quote marker.
803/// Lists hang with spaces of marker width; Markdown quotes repeat the
804/// quote prefix (`> `, `> > `, `>> `, including leading indent). Empty
805/// when `s` is not a marker (headings, fences, inline islands).
806fn hanging_prefix(s: &str) -> String {
807    if is_quote_marker(s) {
808        return s.to_string();
809    }
810    let width = hanging_indent_width(s);
811    if width > 0 {
812        " ".repeat(width)
813    } else {
814        String::new()
815    }
816}
817
818/// True when `s` is a Markdown quote marker: optional indent plus `>`
819/// runs (`> `, `> > `, `>> `).
820fn is_quote_marker(s: &str) -> bool {
821    if s.is_empty() || s.contains('\n') {
822        return false;
823    }
824    let trimmed = s.trim_start_matches(' ');
825    !trimmed.is_empty() && trimmed.contains('>') && trimmed.bytes().all(|b| b == b'>' || b == b' ')
826}
827
828/// Column width of a list marker that continuation lines hang at with
829/// spaces. Zero for quotes and non-markers.
830fn hanging_indent_width(s: &str) -> usize {
831    if s.is_empty() || s.contains('\n') || !s.ends_with(' ') {
832        return 0;
833    }
834    let trimmed = s.trim_start();
835    if trimmed.len() < 2 {
836        return 0;
837    }
838    // Parser markers are `core` plus one trailing space; leading indent is
839    // part of the hang so nested `   - ` continues at column 5.
840    let core = &trimmed[..trimmed.len() - 1];
841    let is_bullet = matches!(core, "-" | "*" | "+");
842    let is_ordered = (core.ends_with('.') || core.ends_with(')'))
843        && core.len() > 1
844        && core[..core.len() - 1].bytes().all(|b| b.is_ascii_digit());
845    if is_bullet || is_ordered {
846        s.chars().count()
847    } else {
848        0
849    }
850}
851
852/// Markdown hard break payloads: two or more spaces plus newline, or `\\\n`.
853fn is_hard_break_structure(s: &str) -> bool {
854    let Some(body) = s.strip_suffix('\n') else {
855        return false;
856    };
857    if body == "\\" {
858        return true;
859    }
860    body.len() >= 2 && body.bytes().all(|b| b == b' ')
861}
862
863fn suppress_prose_trailing_newline(s: &str) -> bool {
864    if s == "\n" || s.starts_with('}') || s.starts_with(']') || s.starts_with(')') {
865        return true;
866    }
867    if is_hard_break_structure(s) {
868        return true;
869    }
870    // Islands may carry a leading space for glue after reflow trims prose.
871    let t = s.trim();
872    // Inline math: single-line `$...$` (not display `$$...$$`).
873    if t.starts_with('$') && !t.starts_with("$$") && !t.contains('\n') {
874        return true;
875    }
876    // Inline code island: single-line `...` (optional trailing space already trimmed).
877    let code = t.trim_end_matches(' ');
878    if code.starts_with('`') && code.ends_with('`') && code.len() >= 2 && !code.contains('\n') {
879        return true;
880    }
881    false
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::sentence::unicode::UnicodeSentenceSplitter;
888
889    fn reflow_text(input: &str) -> String {
890        let regions = vec![Region::Prose(input.to_string())];
891        let config = ReflowConfig::default();
892        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
893    }
894
895    #[test]
896    fn missing_origin_is_error() {
897        let spanned = vec![crate::parser::SpannedRegion::unspanned(Region::Prose(
898            "Hi.".into(),
899        ))];
900        let err = reflow_spanned(
901            "Hi.",
902            &spanned,
903            &UnicodeSentenceSplitter::new(),
904            &ReflowConfig::default(),
905        )
906        .unwrap_err();
907        assert!(err.0.contains("origin"), "{err}");
908    }
909
910    #[test]
911    fn invalid_span_is_error() {
912        use crate::parser::{ByteSpan, RegionOrigin, SpannedRegion};
913        let spanned = vec![SpannedRegion {
914            region: Region::Prose("Hi.".into()),
915            origin: Some(RegionOrigin::Whole(ByteSpan::new(0, 99))),
916        }];
917        let err = reflow_spanned(
918            "Hi.",
919            &spanned,
920            &UnicodeSentenceSplitter::new(),
921            &ReflowConfig::default(),
922        )
923        .unwrap_err();
924        assert!(err.0.contains("invalid splice span"), "{err}");
925    }
926
927    #[test]
928    fn simple_reflow() {
929        let result = reflow_text("Hello world. This is a test. Another sentence.");
930        assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
931    }
932
933    #[test]
934    fn idempotent() {
935        let input = "Hello world.\nThis is a test.\nAnother sentence.";
936        let first = reflow_text(input);
937        let second = reflow_text(&first);
938        assert_eq!(first, second, "reflow must be idempotent");
939    }
940
941    #[test]
942    fn preserves_structure() {
943        let regions = vec![
944            Region::Structure("#+TITLE: Test\n".to_string()),
945            Region::BlankLines("\n".to_string()),
946            Region::Prose("First sentence. Second sentence.".to_string()),
947        ];
948        let config = ReflowConfig::default();
949        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
950        assert_eq!(
951            result,
952            "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
953        );
954    }
955
956    #[test]
957    fn hanging_prefix_quote_repeats_marker_list_uses_spaces() {
958        assert_eq!(hanging_prefix("> "), "> ");
959        assert_eq!(hanging_prefix(">> "), ">> ");
960        assert_eq!(hanging_prefix("  > "), "  > ");
961        assert_eq!(hanging_prefix("- "), "  ");
962        assert_eq!(hanging_prefix("1. "), "   ");
963        assert_eq!(hanging_indent_width("> "), 0);
964        assert_eq!(hanging_indent_width("- "), 2);
965    }
966
967    #[test]
968    fn quote_wrap_lines_repeat_prefix_under_max_width() {
969        let regions = vec![
970            Region::Structure("> ".to_string()),
971            Region::Prose("One two three four five six seven eight.".to_string()),
972            Region::Structure("\n".to_string()),
973        ];
974        let config = ReflowConfig {
975            max_width: 20,
976            ..Default::default()
977        };
978        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
979        let lines: Vec<&str> = result.lines().filter(|l| !l.is_empty()).collect();
980        assert!(lines.len() > 1, "must wrap: {result:?}");
981        for line in &lines {
982            assert!(line.starts_with("> "), "quote wrap keeps `>`: {result:?}");
983            assert!(
984                line.chars().count() <= 20,
985                "prefix counts toward width: {line:?}"
986            );
987        }
988        assert!(
989            !result.contains("\n  "),
990            "quotes do not space-hang: {result:?}"
991        );
992    }
993
994    #[test]
995    fn max_width_wrapping() {
996        let regions = vec![Region::Prose(
997            "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
998        )];
999        let config = ReflowConfig {
1000            max_width: 40,
1001            ..Default::default()
1002        };
1003        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1004        // Every line should be <= 40 chars
1005        for line in result.lines() {
1006            assert!(
1007                line.len() <= 40,
1008                "Line too long: {} chars: {:?}",
1009                line.len(),
1010                line
1011            );
1012        }
1013    }
1014
1015    #[test]
1016    fn clause_breaks_prefer_commas_under_max_width() {
1017        // Issue #7 sample: max_width=80 with clause breaks should land soft
1018        // breaks after the independent-clause commas rather than packing
1019        // mid-phrase as greedy wrap without clause preference does.
1020        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
1021        let wrapped = wrap_with_clause_breaks(sentence, 80);
1022        let expected = "\
1023It contains rules which govern how the Objectives are orchestrated,
1024along with rules which can automatically activate the Objectives in the plan,
1025without additional human intervention.";
1026        assert_eq!(
1027            wrapped, expected,
1028            "clause-first wrap:\n--- got ---\n{wrapped}\n--- expected ---\n{expected}"
1029        );
1030        for line in wrapped.lines() {
1031            assert!(
1032                line.chars().count() <= 80,
1033                "line exceeds max_width: {line:?}"
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn clause_breaks_off_packs_past_first_comma() {
1040        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
1041        let regions = vec![Region::Prose(sentence.to_string())];
1042        let config = ReflowConfig {
1043            max_width: 80,
1044            clause_breaks: false,
1045            ..Default::default()
1046        };
1047        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1048        // Greedy wrap packs past the first comma; clause_breaks would break there.
1049        assert!(
1050            result.contains("orchestrated, along with\n"),
1051            "control path still packs past the first comma: {result:?}"
1052        );
1053        assert!(result.contains('\n'));
1054    }
1055
1056    #[test]
1057    fn clause_breaks_via_reflow_config() {
1058        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
1059        let regions = vec![Region::Prose(sentence.to_string())];
1060        let config = ReflowConfig {
1061            max_width: 80,
1062            clause_breaks: true,
1063            ..Default::default()
1064        };
1065        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1066        assert!(
1067            result.contains("orchestrated,\nalong with"),
1068            "reflow with clause_breaks must break after first comma: {result:?}"
1069        );
1070        assert!(
1071            result.contains("plan,\nwithout"),
1072            "reflow with clause_breaks must break after second comma: {result:?}"
1073        );
1074    }
1075
1076    #[test]
1077    fn clause_breaks_handles_semicolon_colon_emdash() {
1078        let s = "First clause; second clause: third clause — fourth clause.";
1079        // Fits under the limit: no break is forced, the sentence stays whole.
1080        assert_eq!(wrap_with_clause_breaks(s, 80), s);
1081        // Forced under a tight limit: every break lands after clause punctuation.
1082        assert_eq!(
1083            wrap_with_clause_breaks(s, 20),
1084            "First clause;\nsecond clause:\nthird clause —\nfourth clause."
1085        );
1086    }
1087
1088    #[test]
1089    fn clause_breaks_leave_fitting_sentences_alone() {
1090        let regions = vec![Region::Prose(
1091            "Hello, world. Short, sweet, and done.".to_string(),
1092        )];
1093        let config = ReflowConfig {
1094            max_width: 80,
1095            clause_breaks: true,
1096            ..Default::default()
1097        };
1098        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1099        assert_eq!(result, "Hello, world.\nShort, sweet, and done.\n");
1100    }
1101
1102    #[test]
1103    fn clause_breaks_never_split_inside_tokens() {
1104        // Clause punctuation not followed by whitespace stays inside its
1105        // token; a break there would render as an inserted space.
1106        let s = "Totals reached 1,000,000 by 10:30 via https://example.com/a,b using --clause-breaks and rock—paper logic in a sentence long enough to need wrapping.";
1107        let wrapped = wrap_with_clause_breaks(s, 30);
1108        let rejoined: Vec<&str> = wrapped.split_whitespace().collect();
1109        let original: Vec<&str> = s.split_whitespace().collect();
1110        assert_eq!(rejoined, original, "wrapping must be lossless: {wrapped:?}");
1111        for token in [
1112            "1,000,000",
1113            "10:30",
1114            "https://example.com/a,b",
1115            "--clause-breaks",
1116            "rock—paper",
1117        ] {
1118            assert!(
1119                wrapped.lines().any(|l| l.contains(token)),
1120                "{token:?} must stay on a single line: {wrapped:?}"
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn clause_breaks_idempotent() {
1127        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
1128        let config = ReflowConfig {
1129            max_width: 80,
1130            clause_breaks: true,
1131            ..Default::default()
1132        };
1133        let splitter = UnicodeSentenceSplitter::new();
1134        let first = reflow(&[Region::Prose(sentence.to_string())], &splitter, &config);
1135        let second = reflow(
1136            &[Region::Prose(first.trim_end().to_string())],
1137            &splitter,
1138            &config,
1139        );
1140        assert_eq!(first, second, "clause-break reflow must be idempotent");
1141    }
1142
1143    #[test]
1144    fn long_clause_still_word_wraps() {
1145        let long = "This is a deliberately long independent clause without internal punctuation that must still wrap under a tight max width constraint for the test.";
1146        let wrapped = wrap_with_clause_breaks(long, 40);
1147        for line in wrapped.lines() {
1148            assert!(
1149                line.chars().count() <= 40,
1150                "overlong clause must still wrap: {line:?}"
1151            );
1152        }
1153        assert!(wrapped.contains('\n'));
1154    }
1155
1156    const ISSUE7: &str = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
1157
1158    const ISSUE7_CLAUSES: &str = "\
1159It contains rules which govern how the Objectives are orchestrated,
1160along with rules which can automatically activate the Objectives in the plan,
1161without additional human intervention.";
1162
1163    const UDHR: &str = "All human beings are born free and equal in dignity and rights. They are endowed with reason and conscience and should act towards one another in a spirit of brotherhood.";
1164
1165    /// SemBr spec sample: sentence breaks only. The spec's extra break after
1166    /// "conscience" is rule 6 (dependent clause, no punct) and is not inserted.
1167    const UDHR_SENTENCES: &str = "\
1168All human beings are born free and equal in dignity and rights.
1169They are endowed with reason and conscience and should act towards one another in a spirit of brotherhood.
1170";
1171
1172    fn reflow_clauses(input: &str) -> String {
1173        let regions = vec![Region::Prose(input.to_string())];
1174        let config = ReflowConfig {
1175            clause_breaks: true,
1176            ..Default::default()
1177        };
1178        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
1179    }
1180
1181    #[test]
1182    fn clause_breaks_unlimited_issue7_sample() {
1183        assert_eq!(wrap_with_clause_breaks(ISSUE7, 0), ISSUE7_CLAUSES);
1184        let result = reflow_clauses(ISSUE7);
1185        assert_eq!(result, format!("{ISSUE7_CLAUSES}\n"));
1186    }
1187
1188    #[test]
1189    fn clause_breaks_unlimited_udhr_one_clause_stays() {
1190        let result = reflow_clauses(UDHR);
1191        assert_eq!(result, UDHR_SENTENCES);
1192        assert!(
1193            !result.contains("conscience\n"),
1194            "rule 6 conscience break is not inserted: {result:?}"
1195        );
1196        let second = result.lines().nth(1).expect("second sentence");
1197        assert!(
1198            second.contains("conscience and should"),
1199            "second sentence stays one independent clause: {result:?}"
1200        );
1201    }
1202
1203    #[test]
1204    fn clause_breaks_unlimited_off_by_default() {
1205        let result = reflow_text("Hello, world.");
1206        assert_eq!(result, "Hello, world.\n");
1207    }
1208
1209    #[test]
1210    fn clause_breaks_unlimited_breaks_fitting_multi_clause() {
1211        // max_width=0 plus clause_breaks breaks even when the sentence fits.
1212        let result = reflow_clauses("Hello, world.");
1213        assert_eq!(result, "Hello,\nworld.\n");
1214        let result = reflow_clauses("Short, sweet, and done.");
1215        assert_eq!(result, "Short,\nsweet,\nand done.\n");
1216    }
1217
1218    #[test]
1219    fn clause_breaks_unlimited_semicolon_colon_emdash() {
1220        let s = "First clause; second clause: third clause — fourth clause -- fifth.";
1221        assert_eq!(
1222            wrap_with_clause_breaks(s, 0),
1223            "First clause;\nsecond clause:\nthird clause —\nfourth clause --\nfifth."
1224        );
1225    }
1226
1227    #[test]
1228    fn clause_breaks_unlimited_never_split_inside_tokens() {
1229        let s = "Totals reached 1,000,000 by 10:30 via https://example.com/a,b using --clause-breaks and rock—paper logic, then continued.";
1230        let wrapped = wrap_with_clause_breaks(s, 0);
1231        assert_eq!(
1232            wrapped,
1233            "Totals reached 1,000,000 by 10:30 via https://example.com/a,b using --clause-breaks and rock—paper logic,\nthen continued."
1234        );
1235        let rejoined: Vec<&str> = wrapped.split_whitespace().collect();
1236        let original: Vec<&str> = s.split_whitespace().collect();
1237        assert_eq!(rejoined, original, "breaking must be lossless: {wrapped:?}");
1238        for token in [
1239            "1,000,000",
1240            "10:30",
1241            "https://example.com/a,b",
1242            "--clause-breaks",
1243            "rock—paper",
1244        ] {
1245            assert!(
1246                wrapped.lines().any(|l| l.contains(token)),
1247                "{token:?} must stay on a single line: {wrapped:?}"
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn clause_breaks_unlimited_no_render_change_latex_ref() {
1254        // Preamble is structure; wrap the sentence in a document body.
1255        let input =
1256            "\\begin{document}\nSee Eq.~\\ref{eq:diff}, then the next clause.\n\\end{document}\n";
1257        let config = crate::FormatConfig {
1258            format: crate::format::Format::Latex,
1259            clause_breaks: true,
1260            ..Default::default()
1261        };
1262        let result = crate::format_text(input, &config).unwrap();
1263        assert_eq!(
1264            result,
1265            "\\begin{document}\nSee Eq.~\\ref{eq:diff},\nthen the next clause.\n\\end{document}\n"
1266        );
1267        assert!(
1268            result.contains("Eq.~\\ref{eq:diff}"),
1269            "LaTeX ~ must stay attached: {result:?}"
1270        );
1271    }
1272
1273    #[test]
1274    fn clause_breaks_unlimited_no_render_change_markdown_link() {
1275        let input = "See [the example site](https://ex.com/a,b), then more.\n";
1276        let config = crate::FormatConfig {
1277            format: crate::format::Format::Markdown,
1278            clause_breaks: true,
1279            ..Default::default()
1280        };
1281        let result = crate::format_text(input, &config).unwrap();
1282        assert_eq!(
1283            result,
1284            "See [the example site](https://ex.com/a,b),\nthen more.\n"
1285        );
1286        assert!(
1287            result.contains("[the example site](https://ex.com/a,b)"),
1288            "markdown link must stay atomic: {result:?}"
1289        );
1290    }
1291
1292    #[test]
1293    fn clause_breaks_unlimited_no_render_change_hyphenated_words() {
1294        let input = "This well-known state-of-the-art method works, then more.";
1295        let result = reflow_clauses(input);
1296        assert_eq!(
1297            result,
1298            "This well-known state-of-the-art method works,\nthen more.\n"
1299        );
1300        assert!(result.contains("well-known"));
1301        assert!(result.contains("state-of-the-art"));
1302        assert!(
1303            !result.contains("well-\n"),
1304            "must not break inside a hyphenated word: {result:?}"
1305        );
1306    }
1307
1308    #[test]
1309    fn clause_breaks_unlimited_hanging_indent() {
1310        let regions = vec![
1311            Region::Structure("- ".to_string()),
1312            Region::Prose(ISSUE7.to_string()),
1313            Region::Structure("\n".to_string()),
1314        ];
1315        let config = ReflowConfig {
1316            clause_breaks: true,
1317            ..Default::default()
1318        };
1319        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1320        assert_eq!(
1321            result,
1322            "\
1323- It contains rules which govern how the Objectives are orchestrated,
1324  along with rules which can automatically activate the Objectives in the plan,
1325  without additional human intervention.
1326"
1327        );
1328    }
1329
1330    #[test]
1331    fn clause_breaks_unlimited_quote_repeats_prefix() {
1332        let regions = vec![
1333            Region::Structure("> ".to_string()),
1334            Region::Prose("First clause, second clause.".to_string()),
1335            Region::Structure("\n".to_string()),
1336        ];
1337        let config = ReflowConfig {
1338            clause_breaks: true,
1339            ..Default::default()
1340        };
1341        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1342        assert_eq!(result, "> First clause,\n> second clause.\n");
1343    }
1344
1345    #[test]
1346    fn clause_breaks_unlimited_idempotent() {
1347        let first = reflow_clauses(ISSUE7);
1348        let second = reflow_clauses(first.trim_end());
1349        assert_eq!(
1350            first, second,
1351            "unlimited clause-break reflow must be idempotent"
1352        );
1353        let again = crate::format_text(
1354            &first,
1355            &crate::FormatConfig {
1356                format: crate::format::Format::Plaintext,
1357                clause_breaks: true,
1358                ..Default::default()
1359            },
1360        )
1361        .unwrap();
1362        assert_eq!(first, again);
1363    }
1364
1365    #[test]
1366    fn clause_breaks_under_max_width_still_leaves_fitting_alone() {
1367        // max_width > 0 keeps the wrap-prefer path: a fitting sentence
1368        // is not force-broken even when clause_breaks is on.
1369        assert_eq!(
1370            wrap_with_clause_breaks("Hello, world.", 80),
1371            "Hello, world."
1372        );
1373        assert_eq!(wrap_with_clause_breaks(ISSUE7, 80), ISSUE7_CLAUSES);
1374    }
1375
1376    fn reflow_regions(regions: Vec<Region>) -> String {
1377        reflow(
1378            &regions,
1379            &UnicodeSentenceSplitter::new(),
1380            &ReflowConfig::default(),
1381        )
1382    }
1383
1384    #[test]
1385    fn hanging_indent_width_markers_only() {
1386        assert_eq!(hanging_indent_width("- "), 2);
1387        assert_eq!(hanging_indent_width("* "), 2);
1388        assert_eq!(hanging_indent_width("+ "), 2);
1389        assert_eq!(hanging_indent_width("1. "), 3);
1390        assert_eq!(hanging_indent_width("10. "), 4);
1391        assert_eq!(hanging_indent_width("1) "), 3);
1392        assert_eq!(hanging_indent_width("   - "), 5);
1393        // Quotes are a prefix hang, not a space-hang bullet.
1394        assert_eq!(hanging_indent_width("> "), 0);
1395        assert_eq!(hanging_indent_width("> > "), 0);
1396        assert_eq!(hanging_prefix("> "), "> ");
1397        assert_eq!(hanging_prefix("> > "), "> > ");
1398        assert_eq!(hanging_prefix("  > "), "  > ");
1399        assert_eq!(hanging_prefix("- "), "  ");
1400        assert_eq!(hanging_prefix("1. "), "   ");
1401        assert_eq!(hanging_indent_width("\n"), 0);
1402        assert_eq!(hanging_indent_width("#+TITLE: Test\n"), 0);
1403        assert_eq!(hanging_indent_width("$x$"), 0);
1404        assert_eq!(hanging_indent_width("`code`"), 0);
1405        assert_eq!(hanging_indent_width("## heading\n"), 0);
1406    }
1407
1408    #[test]
1409    fn list_hanging_indent_second_sentence() {
1410        let result = reflow_regions(vec![
1411            Region::Structure("- ".to_string()),
1412            Region::Prose("One. Two.".to_string()),
1413            Region::Structure("\n".to_string()),
1414        ]);
1415        assert_eq!(result, "- One.\n  Two.\n");
1416    }
1417
1418    #[test]
1419    fn numbered_list_hanging_indent() {
1420        let result = reflow_regions(vec![
1421            Region::Structure("1. ".to_string()),
1422            Region::Prose("One. Two.".to_string()),
1423            Region::Structure("\n".to_string()),
1424        ]);
1425        assert_eq!(result, "1. One.\n   Two.\n");
1426    }
1427
1428    #[test]
1429    fn quote_hanging_indent() {
1430        let result = reflow_regions(vec![
1431            Region::Structure("> ".to_string()),
1432            Region::Prose("One. Two.".to_string()),
1433            Region::Structure("\n".to_string()),
1434        ]);
1435        assert_eq!(result, "> One.\n> Two.\n");
1436    }
1437
1438    #[test]
1439    fn nested_quote_repeats_full_prefix() {
1440        let result = reflow_regions(vec![
1441            Region::Structure("> ".to_string()),
1442            Region::Prose("Quoted one. Quoted two.".to_string()),
1443            Region::Structure("\n".to_string()),
1444            Region::Structure("> > ".to_string()),
1445            Region::Prose("Nested one. Nested two.".to_string()),
1446            Region::Structure("\n".to_string()),
1447        ]);
1448        assert_eq!(
1449            result,
1450            "> Quoted one.\n> Quoted two.\n> > Nested one.\n> > Nested two.\n"
1451        );
1452    }
1453
1454    #[test]
1455    fn nested_list_items_do_not_flatten() {
1456        let result = reflow_regions(vec![
1457            Region::Structure("1. ".to_string()),
1458            Region::Prose("Parent one. Parent two.".to_string()),
1459            Region::Structure("\n".to_string()),
1460            Region::Structure("   - ".to_string()),
1461            Region::Prose("Child one. Child two.".to_string()),
1462            Region::Structure("\n".to_string()),
1463        ]);
1464        assert_eq!(
1465            result,
1466            "1. Parent one.\n   Parent two.\n   - Child one.\n     Child two.\n"
1467        );
1468        assert!(
1469            result.contains("\n   - Child one."),
1470            "nested marker must stay its own item: {result:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn adjacent_list_items_are_not_merged() {
1476        let result = reflow_regions(vec![
1477            Region::Structure("- ".to_string()),
1478            Region::Prose("First item. More first.".to_string()),
1479            Region::Structure("\n".to_string()),
1480            Region::Structure("- ".to_string()),
1481            Region::Prose("Second item. More second.".to_string()),
1482            Region::Structure("\n".to_string()),
1483        ]);
1484        assert_eq!(
1485            result,
1486            "- First item.\n  More first.\n- Second item.\n  More second.\n"
1487        );
1488    }
1489
1490    #[test]
1491    fn single_sentence_list_item_has_no_extra_indent() {
1492        let result = reflow_regions(vec![
1493            Region::Structure("- ".to_string()),
1494            Region::Prose("Only one sentence.".to_string()),
1495            Region::Structure("\n".to_string()),
1496        ]);
1497        assert_eq!(result, "- Only one sentence.\n");
1498    }
1499
1500    #[test]
1501    fn wrap_lines_under_list_also_hang() {
1502        let regions = vec![
1503            Region::Structure("- ".to_string()),
1504            Region::Prose(
1505                "This is a deliberately long first sentence that must wrap. Short.".to_string(),
1506            ),
1507            Region::Structure("\n".to_string()),
1508        ];
1509        let config = ReflowConfig {
1510            max_width: 32,
1511            ..Default::default()
1512        };
1513        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1514        let lines: Vec<&str> = result.lines().collect();
1515        assert!(
1516            lines[0].starts_with("- "),
1517            "first line keeps marker: {result:?}"
1518        );
1519        for line in &lines[1..] {
1520            assert!(
1521                line.starts_with("  "),
1522                "wrap/continuation must hang at marker width: {result:?}"
1523            );
1524            assert!(
1525                !line.starts_with("- "),
1526                "must not invent a new list item: {result:?}"
1527            );
1528        }
1529        for line in &lines {
1530            assert!(
1531                line.chars().count() <= 32,
1532                "line exceeds max_width: {line:?}"
1533            );
1534        }
1535    }
1536
1537    fn wrap_sentence(sentence: &str, max_width: usize, clause_breaks: bool) -> String {
1538        let regions = vec![Region::Prose(sentence.to_string())];
1539        let config = ReflowConfig {
1540            max_width,
1541            clause_breaks,
1542            ..Default::default()
1543        };
1544        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
1545    }
1546
1547    fn assert_atomic_token(wrapped: &str, token: &str) {
1548        assert!(
1549            wrapped.lines().any(|l| l.contains(token)),
1550            "{token:?} must stay on a single line:\n{wrapped}"
1551        );
1552        assert!(
1553            !wrapped.contains('\u{00a0}'),
1554            "wrap must not inject NBSP:\n{wrapped}"
1555        );
1556    }
1557
1558    #[test]
1559    fn max_width_keeps_markdown_link_atomic() {
1560        let token = "[the example site](https://ex.com)";
1561        let sentence = "Please consult [the example site](https://ex.com) today.";
1562        for clause in [false, true] {
1563            let wrapped = wrap_sentence(sentence, 40, clause);
1564            assert_atomic_token(&wrapped, token);
1565        }
1566    }
1567
1568    #[test]
1569    fn max_width_keeps_markdown_image_atomic() {
1570        let token = "![alt text here](https://img.example.com/a.png)";
1571        let sentence = "Look at ![alt text here](https://img.example.com/a.png) now please.";
1572        for clause in [false, true] {
1573            let wrapped = wrap_sentence(sentence, 36, clause);
1574            assert_atomic_token(&wrapped, token);
1575        }
1576    }
1577
1578    #[test]
1579    fn max_width_keeps_inline_code_atomic() {
1580        let token = "`some long inline code`";
1581        let sentence = "Use `some long inline code` today.";
1582        for clause in [false, true] {
1583            let wrapped = wrap_sentence(sentence, 20, clause);
1584            assert_atomic_token(&wrapped, token);
1585        }
1586    }
1587
1588    #[test]
1589    fn max_width_keeps_org_link_atomic() {
1590        let token = "[[https://example.com][the example site]]";
1591        let sentence = "See [[https://example.com][the example site]] now.";
1592        for clause in [false, true] {
1593            let wrapped = wrap_sentence(sentence, 30, clause);
1594            assert_atomic_token(&wrapped, token);
1595        }
1596    }
1597
1598    #[test]
1599    fn max_width_keeps_math_atomic() {
1600        let token = "$E = m c^{2}$";
1601        let sentence = "The identity $E = m c^{2}$ holds in this frame.";
1602        for clause in [false, true] {
1603            let wrapped = wrap_sentence(sentence, 24, clause);
1604            assert_atomic_token(&wrapped, token);
1605        }
1606    }
1607
1608    #[test]
1609    fn max_width_keeps_autolink_atomic() {
1610        let token = "<https://example.com/a/long-path>";
1611        let sentence = "Visit <https://example.com/a/long-path> today.";
1612        // Format::Markdown: wrap-created `<https://...>` must stay an
1613        // autolink, not a substring of `\<https://...` (HTML-opener escape).
1614        for clause in [false, true] {
1615            let regions = vec![Region::Prose(sentence.to_string())];
1616            let config = ReflowConfig {
1617                max_width: 24,
1618                clause_breaks: clause,
1619                format: Format::Markdown,
1620                ..Default::default()
1621            };
1622            let wrapped = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1623            assert!(
1624                wrapped.lines().any(|l| l.trim() == token),
1625                "autolink must appear unchanged, not escaped:\n{wrapped}"
1626            );
1627            assert!(
1628                !wrapped.contains("\\<"),
1629                "must not inject \\ into the autolink:\n{wrapped}"
1630            );
1631            assert!(!wrapped.contains('\u{00a0}'));
1632        }
1633        let email = "<user@example.com>";
1634        let email_sentence = "Write <user@example.com> today please.";
1635        let regions = vec![Region::Prose(email_sentence.to_string())];
1636        let config = ReflowConfig {
1637            max_width: 20,
1638            format: Format::Markdown,
1639            ..Default::default()
1640        };
1641        let wrapped = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
1642        assert!(
1643            wrapped.lines().any(|l| l.trim() == email),
1644            "email autolink must appear unchanged:\n{wrapped}"
1645        );
1646        assert!(
1647            !wrapped.contains("\\<"),
1648            "email autolink escaped:\n{wrapped}"
1649        );
1650    }
1651
1652    #[test]
1653    fn overlong_atomic_token_sits_alone() {
1654        let token =
1655            "[a deliberately long link description that exceeds width](https://example.com)";
1656        let sentence = format!("See {token} now.");
1657        for clause in [false, true] {
1658            let wrapped = wrap_sentence(&sentence, 20, clause);
1659            assert_atomic_token(&wrapped, token);
1660            let line = wrapped
1661                .lines()
1662                .find(|l| l.contains(token))
1663                .expect("token line");
1664            assert_eq!(line.trim(), token, "overlong token sits alone:\n{wrapped}");
1665        }
1666    }
1667
1668    #[test]
1669    fn textwrap_path_never_splits_numeric_url_or_flag_tokens() {
1670        let s = "Totals reached 1,000,000 by 10:30 via https://example.com/a,b using --clause-breaks in a sentence long enough to wrap.";
1671        let wrapped = wrap_sentence(s, 30, false);
1672        let rejoined: Vec<&str> = wrapped.split_whitespace().collect();
1673        let original: Vec<&str> = s.split_whitespace().collect();
1674        assert_eq!(rejoined, original, "wrapping must be lossless: {wrapped:?}");
1675        for token in [
1676            "1,000,000",
1677            "10:30",
1678            "https://example.com/a,b",
1679            "--clause-breaks",
1680        ] {
1681            assert!(
1682                wrapped.lines().any(|l| l.contains(token)),
1683                "{token:?} must stay on a single line: {wrapped:?}"
1684            );
1685        }
1686        assert!(!wrapped.contains('\u{00a0}'));
1687    }
1688
1689    #[test]
1690    fn wrap_created_dash_escaped_in_markdown() {
1691        // "The options are apples" is 22 chars; width 23 breaks before "-".
1692        let input = "The options are apples - oranges extra.";
1693        let config = crate::FormatConfig {
1694            format: crate::format::Format::Markdown,
1695            max_width: 23,
1696            ..Default::default()
1697        };
1698        let result = crate::format_text(input, &config).unwrap();
1699        assert!(
1700            !result.lines().any(|l| l.starts_with("- ")),
1701            "wrap must not invent a list:\n{result}"
1702        );
1703        assert!(
1704            result.lines().any(|l| l.starts_with("\\- ")),
1705            "wrap-created dash must be markdown-escaped:\n{result}"
1706        );
1707        assert!(!result.contains('\u{00a0}'));
1708    }
1709
1710    #[test]
1711    fn wrap_created_hash_star_plus_gt_and_ordered_escaped_in_markdown() {
1712        let config = crate::FormatConfig {
1713            format: crate::format::Format::Markdown,
1714            max_width: 23,
1715            ..Default::default()
1716        };
1717        let cases = [
1718            ("The options are apples * oranges extra.", "\\* "),
1719            ("The options are apples + oranges extra.", "\\+ "),
1720            ("The options are apples > oranges extra.", "\\> "),
1721            ("The options are apples # oranges extra.", "\\# "),
1722            ("The options are apples 1. oranges extra.", "1\\. "),
1723        ];
1724        for (input, escaped_prefix) in cases {
1725            let result = crate::format_text(input, &config).unwrap();
1726            assert!(
1727                result.lines().any(|l| l.starts_with(escaped_prefix)),
1728                "expected a wrap-created line starting {escaped_prefix:?}:\n{result}"
1729            );
1730            assert!(
1731                !result.lines().any(|l| {
1732                    l.starts_with("* ")
1733                        || l.starts_with("+ ")
1734                        || l.starts_with("> ")
1735                        || l.starts_with("# ")
1736                        || l.starts_with("1. ")
1737                }),
1738                "wrap must not invent a block:\n{result}"
1739            );
1740        }
1741    }
1742
1743    #[test]
1744    fn wrap_created_dash_skips_cut_in_org() {
1745        let input = "The options are apples - oranges extra.";
1746        let config = crate::FormatConfig {
1747            format: crate::format::Format::Org,
1748            max_width: 23,
1749            ..Default::default()
1750        };
1751        let result = crate::format_text(input, &config).unwrap();
1752        assert!(
1753            !result.lines().any(|l| l.starts_with("- ")),
1754            "wrap must not invent an Org list:\n{result}"
1755        );
1756        assert!(
1757            !result.contains('\\'),
1758            "Org skips the cut instead of backslash-escaping:\n{result}"
1759        );
1760        assert!(
1761            result.contains("apples -"),
1762            "dash stays on the previous line:\n{result}"
1763        );
1764    }
1765
1766    #[test]
1767    fn list_item_first_line_is_not_escaped() {
1768        let input = "- item that is long enough to wrap onto a second line of words";
1769        let config = crate::FormatConfig {
1770            format: crate::format::Format::Markdown,
1771            max_width: 24,
1772            ..Default::default()
1773        };
1774        let result = crate::format_text(input, &config).unwrap();
1775        assert!(
1776            result.starts_with("- item"),
1777            "first line of a list item stays a list:\n{result}"
1778        );
1779        assert!(
1780            !result.starts_with("\\-"),
1781            "must not escape the real list marker:\n{result}"
1782        );
1783    }
1784
1785    #[test]
1786    fn wrap_escape_is_idempotent() {
1787        let input = "The options are apples - oranges extra.";
1788        let config = crate::FormatConfig {
1789            format: crate::format::Format::Markdown,
1790            max_width: 23,
1791            ..Default::default()
1792        };
1793        let first = crate::format_text(input, &config).unwrap();
1794        assert!(
1795            first.lines().any(|l| l.starts_with("\\- ")),
1796            "first pass must emit the wrap-created escape:\n{first}"
1797        );
1798        let second = crate::format_text(&first, &config).unwrap();
1799        assert_eq!(first, second, "second pass must not change output");
1800        assert!(
1801            !first.contains("\\\\"),
1802            "second pass must not accumulate backslashes:\n{first}"
1803        );
1804        let third = crate::format_text(&second, &config).unwrap();
1805        assert_eq!(second, third);
1806    }
1807
1808    fn wrap_fmt(input: &str, width: usize, format: crate::format::Format) -> String {
1809        // Drive wrap directly so the format parser cannot swallow the
1810        // interrupt token before `--max-width` sees it.
1811        let regions = vec![Region::Prose(input.to_string())];
1812        let config = ReflowConfig {
1813            max_width: width,
1814            format,
1815            ..Default::default()
1816        };
1817        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
1818    }
1819
1820    fn assert_no_col0_block(result: &str, starts: &[&str]) {
1821        for line in result.lines() {
1822            for prefix in starts {
1823                assert!(
1824                    !line.starts_with(prefix),
1825                    "wrap must not invent a block starting {prefix:?}:\n{result}"
1826                );
1827            }
1828        }
1829    }
1830
1831    // Review cases A–H: interrupt predicate is format grammar at column 0.
1832
1833    #[test]
1834    fn wrap_created_fence_is_not_a_markdown_block() {
1835        // A. ``` / ~~~
1836        let tick = wrap_fmt(
1837            "The options are apples ``` extra words here.",
1838            23,
1839            crate::format::Format::Markdown,
1840        );
1841        assert_no_col0_block(&tick, &["```"]);
1842        let tilde = wrap_fmt(
1843            "The options are apples ~~~ extra words here.",
1844            23,
1845            crate::format::Format::Markdown,
1846        );
1847        assert_no_col0_block(&tilde, &["~~~"]);
1848    }
1849
1850    #[test]
1851    fn wrap_created_thematic_break_is_not_a_markdown_block() {
1852        // B. --- / === / *** / ___
1853        for token in ["---", "===", "***", "___"] {
1854            let input = format!("The options are apples {token} extra words here.");
1855            let result = wrap_fmt(&input, 23, crate::format::Format::Markdown);
1856            assert_no_col0_block(&result, &[token]);
1857        }
1858    }
1859
1860    #[test]
1861    fn wrap_created_link_ref_is_not_a_markdown_block() {
1862        // C. [ref]:
1863        let result = wrap_fmt(
1864            "The options are apples [ref]: https://ex.com extra.",
1865            23,
1866            crate::format::Format::Markdown,
1867        );
1868        assert_no_col0_block(&result, &["[ref]:", "[ref]: "]);
1869    }
1870
1871    #[test]
1872    fn wrap_created_html_tag_is_not_a_markdown_block() {
1873        // D. HTML tags
1874        let result = wrap_fmt(
1875            "The options are apples <div> extra words here.",
1876            23,
1877            crate::format::Format::Markdown,
1878        );
1879        assert_no_col0_block(&result, &["<div>", "<div "]);
1880    }
1881
1882    #[test]
1883    fn wrap_created_gt_without_space_is_not_a_blockquote() {
1884        // E. >foo
1885        let result = wrap_fmt(
1886            "The options are apples >foo extra words here.",
1887            23,
1888            crate::format::Format::Markdown,
1889        );
1890        assert_no_col0_block(&result, &[">foo", "> foo", ">"]);
1891        assert!(
1892            result.lines().any(|l| l.contains("foo")),
1893            "content must remain:\n{result}"
1894        );
1895    }
1896
1897    #[test]
1898    fn wrap_created_latex_comment_and_commands_are_not_blocks() {
1899        // F. LaTeX % and \begin / \section. Leading `\` is not an MD escape.
1900        let pct = wrap_fmt(
1901            "The options are apples % extra words here.",
1902            23,
1903            crate::format::Format::Latex,
1904        );
1905        assert_no_col0_block(&pct, &["% ", "%"]);
1906        assert!(
1907            pct.contains("apples %"),
1908            "percent stays with previous line:\n{pct}"
1909        );
1910
1911        let begin = wrap_fmt(
1912            "The options are apples \\begin{equation} extra words.",
1913            23,
1914            crate::format::Format::Latex,
1915        );
1916        assert_no_col0_block(&begin, &["\\begin", "\\begin{equation}"]);
1917        assert!(
1918            begin.contains("apples \\begin"),
1919            "\\begin is not an MD escape; skip-cut must keep it:\n{begin}"
1920        );
1921
1922        let section = wrap_fmt(
1923            "The options are apples \\section{Foo} extra words.",
1924            23,
1925            crate::format::Format::Latex,
1926        );
1927        assert_no_col0_block(&section, &["\\section", "\\section{Foo}"]);
1928        assert!(
1929            section.contains("apples \\section"),
1930            "\\section is not an MD escape:\n{section}"
1931        );
1932    }
1933
1934    #[test]
1935    fn wrap_created_rst_directive_is_not_a_block() {
1936        // G. RST ..
1937        let result = wrap_fmt(
1938            "The options are apples .. extra words here.",
1939            23,
1940            crate::format::Format::Rst,
1941        );
1942        assert_no_col0_block(&result, &[".. ", ".."]);
1943        assert!(
1944            result.contains("apples .."),
1945            "RST skip-cut keeps the directive marker:\n{result}"
1946        );
1947    }
1948
1949    #[test]
1950    fn wrap_created_org_table_pipe_is_not_a_block() {
1951        // H. Org |
1952        let result = wrap_fmt(
1953            "The options are apples | extra words here.",
1954            23,
1955            crate::format::Format::Org,
1956        );
1957        assert_no_col0_block(&result, &["| ", "|"]);
1958        assert!(
1959            result.contains("apples |"),
1960            "Org skip-cut keeps the pipe:\n{result}"
1961        );
1962    }
1963
1964    #[test]
1965    fn skip_cut_loops_until_next_line_is_not_a_block() {
1966        // Org: break_at += 1 only eats `-`, then `* oranges` is a headline.
1967        let result = wrap_fmt(
1968            "The options are apples - * oranges extra.",
1969            23,
1970            crate::format::Format::Org,
1971        );
1972        assert_no_col0_block(&result, &["- ", "* ", "*"]);
1973        assert!(
1974            result.contains("apples - *"),
1975            "both markers stay on the previous line:\n{result}"
1976        );
1977    }
1978
1979    #[test]
1980    fn wrap_does_not_hyphenate_well_known_or_hyphenated_urls() {
1981        let known = wrap_sentence(
1982            "This is a well-known example in a sentence long enough to wrap here.",
1983            20,
1984            false,
1985        );
1986        assert!(
1987            known.lines().any(|l| l.contains("well-known")),
1988            "must not hyphen-split well-known:\n{known}"
1989        );
1990        let url = "https://example.com/well-known-path-name";
1991        let wrapped = wrap_sentence(
1992            &format!("See {url} extra words to force a wrap here."),
1993            24,
1994            false,
1995        );
1996        assert_atomic_token(&wrapped, url);
1997        assert!(!wrapped.contains('\u{00a0}'));
1998    }
1999
2000    #[test]
2001    fn wrap_created_list_lines_hang_and_interrupt_after_indent() {
2002        // Prefix width on line 0; indent on wrap-created lines; col-0 escape
2003        // is fallback. A dash inside the item must not become a new list.
2004        let result = crate::format_text(
2005            "- The options are apples - oranges extra words.",
2006            &crate::FormatConfig {
2007                format: crate::format::Format::Markdown,
2008                max_width: 25,
2009                ..Default::default()
2010            },
2011        )
2012        .unwrap();
2013        assert!(
2014            result.starts_with("- The options"),
2015            "list first line stays a list:\n{result}"
2016        );
2017        let mut lines = result.lines();
2018        let first = lines.next().expect("first line");
2019        assert!(first.starts_with("- "), "{first:?}");
2020        for line in result.lines().skip(1) {
2021            let trimmed = line.trim_start();
2022            let indent = line.len() - trimmed.len();
2023            let looks_like_list = trimmed.starts_with("- ")
2024                || trimmed.starts_with("* ")
2025                || trimmed.starts_with("+ ")
2026                || (trimmed.len() >= 3
2027                    && trimmed.as_bytes()[0].is_ascii_digit()
2028                    && (trimmed.contains(". ") || trimmed.contains(") ")));
2029            assert!(
2030                !(indent <= 3 && looks_like_list),
2031                "wrap-created line must not parse as a list:\n{result}"
2032            );
2033            if line.contains("oranges") {
2034                assert!(
2035                    line.starts_with(' ') || line.starts_with('\\'),
2036                    "hang or escape, not column-0 dash:\n{result}"
2037                );
2038            }
2039        }
2040    }
2041}