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
10pub struct ReflowConfig<'a> {
12 pub max_width: usize,
14 pub code: Option<&'a HashMap<String, CodeLang>>,
16 pub format_code: bool,
18 pub clause_breaks: bool,
23 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#[cfg(feature = "cli")]
43const PARALLEL_REGION_THRESHOLD: usize = 32;
44
45pub 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67#[error("{0}")]
68pub struct SpliceError(pub String);
69
70pub 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, ®ions, 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 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 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 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
294fn 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}') || core.ends_with("--")
305}
306
307pub 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 initial_column: usize,
328 first_indent: &'a str,
330 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
351fn 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
455fn 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 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
496fn 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
511fn 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
613fn 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
644fn 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
689fn 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 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
724fn 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 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
797pub(crate) fn is_hanging_marker(s: &str) -> bool {
799 !hanging_prefix(s).is_empty()
800}
801
802fn 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
818fn 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
828fn 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 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
852fn 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 let t = s.trim();
872 if t.starts_with('$') && !t.starts_with("$$") && !t.contains('\n') {
874 return true;
875 }
876 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(®ions, &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(®ions, &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(®ions, &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(®ions, &UnicodeSentenceSplitter::new(), &config);
1004 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 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(®ions, &UnicodeSentenceSplitter::new(), &config);
1048 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(®ions, &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 assert_eq!(wrap_with_clause_breaks(s, 80), s);
1081 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(®ions, &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 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 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(®ions, &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 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 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(®ions, &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(®ions, &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 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 ®ions,
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 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(®ions, &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(®ions, &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 = "";
1571 let sentence = "Look at  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 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(®ions, &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(®ions, &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 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 let regions = vec![Region::Prose(input.to_string())];
1812 let config = ReflowConfig {
1813 max_width: width,
1814 format,
1815 ..Default::default()
1816 };
1817 reflow(®ions, &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 #[test]
1834 fn wrap_created_fence_is_not_a_markdown_block() {
1835 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 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 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 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 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 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(§ion, &["\\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 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 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 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 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}