lattice_grammar/reflow.rs
1//! The text-reflow engine — re-break a range of lines to `textwidth`.
2//!
3//! Design: `docs/dev/architecture/text-reflow.md` §4.
4//! Sequencing: `docs/dev/operations/slice-plans/text-reflow.md` (this is
5//! RF.1).
6//!
7//! A pure function of `(lines, textwidth, comment leader, indent)`. No
8//! I/O, no syntax tree, no config lookup — the host resolves the options
9//! and hands the values down, exactly as it does for the indent unit.
10//!
11//! ## Why it lives in `lattice-grammar`
12//!
13//! Heuristic #6: it carves out no dependency surface, so it earns no
14//! crate. The operator that drives it ([`crate::builtins`]) reads
15//! [`crate::GrammarEnv`], which already carries `comment_syntax` (N.1.6)
16//! and `indent` (IN.0); the host's insert path (RF.3) calls the same
17//! module and already depends on this crate.
18//!
19//! `lattice-format` was the other candidate and is the wrong one: that
20//! crate is process spawning, timeouts and diff-derived edits — a
21//! different mechanism that happens to share the word "format".
22//!
23//! ## What it is not
24//!
25//! Not a reformatter. It moves line breaks and normalises interior
26//! whitespace within a paragraph, and touches nothing else — no
27//! reindentation of code, no reordering, no syntax awareness.
28
29use unicode_width::UnicodeWidthStr;
30
31/// Display width of `s` in terminal columns.
32///
33/// Columns, not bytes and not chars: bytes mis-measure every non-ASCII
34/// line, and chars mis-measure CJK (2 columns) and combining marks (0).
35/// The renderer already measures this way, so a reflowed line lands
36/// where the user was told it would.
37pub fn display_width(s: &str) -> usize {
38 UnicodeWidthStr::width(s)
39}
40
41/// The fixed prefix every line of a paragraph carries: indentation plus
42/// any comment leader, e.g. `" /// "` or `"# "` or just `" "`.
43///
44/// [`Self::first`] and [`Self::rest`] differ only for a list item, whose
45/// continuation lines align to the text column rather than repeating the
46/// marker (§4.3).
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct Prefix {
49 /// What the paragraph's first output line starts with.
50 pub first: String,
51 /// What every subsequent output line starts with.
52 pub rest: String,
53}
54
55impl Prefix {
56 fn uniform(p: String) -> Self {
57 Prefix {
58 first: p.clone(),
59 rest: p,
60 }
61 }
62}
63
64/// One paragraph found inside a reflow range.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Paragraph {
67 /// Index of the paragraph's first line, relative to the range start.
68 pub start: usize,
69 /// One past the paragraph's last line, relative to the range start.
70 pub end: usize,
71 /// The prefix its output lines carry.
72 pub prefix: Prefix,
73 /// Whether this run is fillable at all. Blank lines and fenced code
74 /// are carried through verbatim; the whole paragraph model stays one
75 /// list so a consumer cannot forget to re-emit the gaps.
76 pub fillable: bool,
77}
78
79/// Everything the engine needs that it cannot derive from the text.
80#[derive(Debug, Clone, Copy)]
81pub struct ReflowConfig<'a> {
82 /// Target column. Always a real column — `autowrap=off` is what
83 /// turns wrapping off, so this never carries a "disabled" sentinel.
84 pub textwidth: usize,
85 /// The language's line-comment leader (`//`, `#`, `--`), if it has
86 /// one. `None` for markdown, plain text and any language whose
87 /// comment syntax is undeclared — reflow then treats indentation
88 /// alone as the prefix, which is the right answer for prose.
89 pub line_comment: Option<&'a str>,
90}
91
92/// Split `lines` into paragraphs (§4.1).
93///
94/// A new paragraph begins at a blank line, a leader-only line, a change
95/// of prefix, a change of indent, or a list marker. Blank, leader-only
96/// and fenced runs come back as `fillable: false` so they survive the
97/// round trip verbatim — carrying them in the same list as the fillable
98/// runs is what makes it impossible to drop them by forgetting a branch.
99pub fn paragraphs(lines: &[&str], cfg: ReflowConfig<'_>) -> Vec<Paragraph> {
100 let mut out: Vec<Paragraph> = Vec::new();
101 let mut i = 0usize;
102 // §4.5: inside a fence, line breaks are content. Tracked as a walk
103 // state rather than looked up per line because a fence is defined by
104 // its opener, and only a scan from the top of the range knows.
105 let mut fenced = false;
106 while i < lines.len() {
107 let start = i;
108
109 if fenced || is_fence_delimiter(lines[i]) {
110 // Carry the opener, the body and the closer through
111 // untouched. `fenced` flips on the opener and off on the
112 // next delimiter.
113 loop {
114 if is_fence_delimiter(lines[i]) {
115 fenced = !fenced;
116 }
117 i += 1;
118 if i >= lines.len() || !fenced {
119 break;
120 }
121 }
122 out.push(Paragraph {
123 start,
124 end: i,
125 prefix: Prefix::default(),
126 fillable: false,
127 });
128 continue;
129 }
130
131 if is_separator(lines[i], cfg) {
132 // A run of separators is carried through untouched.
133 while i < lines.len() && is_separator(lines[i], cfg) && !is_fence_delimiter(lines[i]) {
134 i += 1;
135 }
136 out.push(Paragraph {
137 start,
138 end: i,
139 prefix: Prefix::default(),
140 fillable: false,
141 });
142 continue;
143 }
144
145 // A fillable run: extend while the next line is not a separator,
146 // not a fence, and continues this paragraph.
147 let head = LineParts::of(lines[i], cfg);
148 i += 1;
149 while i < lines.len()
150 && !is_separator(lines[i], cfg)
151 && !is_fence_delimiter(lines[i])
152 && head.continues(lines[i], cfg)
153 {
154 i += 1;
155 }
156 out.push(Paragraph {
157 start,
158 end: i,
159 prefix: paragraph_prefix(&lines[start..i], cfg),
160 fillable: true,
161 });
162 }
163 out
164}
165
166/// Reflow one already-identified paragraph into output lines.
167///
168/// Greedy fill: words are appended while the result still fits. A word
169/// that cannot fit on a line of its own **overflows rather than being
170/// split** — vim, Emacs `fill-paragraph` and Rewrap all agree, and it is
171/// what keeps a long URL in a comment intact.
172pub fn fill(lines: &[&str], prefix: &Prefix, textwidth: usize) -> Vec<String> {
173 let mut words: Vec<&str> = Vec::new();
174 for (n, line) in lines.iter().enumerate() {
175 let body = strip_known_prefix(line, if n == 0 { &prefix.first } else { &prefix.rest });
176 words.extend(body.split_whitespace());
177 }
178 if words.is_empty() {
179 // A paragraph of nothing but prefix: emit it back unchanged
180 // rather than an empty line, which would delete the leader.
181 return lines.iter().map(|l| l.trim_end().to_string()).collect();
182 }
183
184 let mut out: Vec<String> = Vec::new();
185 let mut cur = prefix.first.clone();
186 let mut cur_has_word = false;
187 for w in words {
188 if !cur_has_word {
189 cur.push_str(w);
190 cur_has_word = true;
191 continue;
192 }
193 // `+ 1` for the space that would join them.
194 if display_width(&cur) + 1 + display_width(w) <= textwidth {
195 cur.push(' ');
196 cur.push_str(w);
197 } else {
198 out.push(cur);
199 cur = prefix.rest.clone();
200 cur.push_str(w);
201 }
202 }
203 out.push(cur);
204 out
205}
206
207/// Reflow a whole range: paragraphs found, fillable ones filled,
208/// everything else carried through.
209///
210/// Returns the replacement lines for the range. A range that reflows to
211/// itself returns an equal `Vec`, which is what lets the operator skip
212/// the edit entirely (RF.2) rather than pushing a no-op undo step.
213pub fn reflow_range(lines: &[&str], cfg: ReflowConfig<'_>) -> Vec<String> {
214 let mut out = Vec::new();
215 for p in paragraphs(lines, cfg) {
216 if !p.fillable {
217 out.extend(lines[p.start..p.end].iter().map(|l| l.to_string()));
218 continue;
219 }
220 // §10: a prefix at or past the margin leaves no room for even
221 // one word, and greedy filling would emit one word per line
222 // forever. Leaving the paragraph alone is the honest answer.
223 if display_width(&p.prefix.rest) >= cfg.textwidth {
224 out.extend(lines[p.start..p.end].iter().map(|l| l.to_string()));
225 continue;
226 }
227 out.extend(fill(&lines[p.start..p.end], &p.prefix, cfg.textwidth));
228 }
229 out
230}
231
232/// Where auto-wrap should break the line being typed on, and what the
233/// carried remainder needs in front of it.
234///
235/// Byte offsets into the line, not the buffer — the host adds the row.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct AutoWrapBreak {
238 /// Start of the whitespace run being replaced by the newline.
239 pub start: usize,
240 /// End of that run — the first byte of the word moving down.
241 pub end: usize,
242 /// Text to splice in: a newline plus the continuation prefix.
243 pub replacement: String,
244}
245
246/// Decide whether the line the cursor sits on should break, and where
247/// (§9).
248///
249/// Called on the **keystroke path**, once per inserted character, so it
250/// is a single scan of the current line and nothing else. No tree, no
251/// buffer walk, no allocation beyond the replacement string on the rare
252/// frame that actually breaks.
253///
254/// Returns `None` — leave the line alone — when:
255///
256/// - the cursor has not passed `textwidth` yet;
257/// - there is no whitespace to break at after the prefix, i.e. the
258/// overlong thing is one word. Vim, Emacs and Rewrap all agree that a
259/// long URL overflows rather than being split;
260/// - the only break points are past the margin, so breaking would not
261/// help.
262pub fn auto_wrap_break(
263 line: &str,
264 cursor_byte: usize,
265 cfg: ReflowConfig<'_>,
266) -> Option<AutoWrapBreak> {
267 let cursor_byte = cursor_byte.min(line.len());
268 if display_width(&line[..cursor_byte]) <= cfg.textwidth {
269 return None;
270 }
271 // Everything up to and including the comment marker is structure and
272 // is never a break point — breaking inside `///` would produce `//`
273 // and a stray `/`.
274 let indent = indent_of(line);
275 let marker = marker_of(&line[indent.len()..], cfg.line_comment);
276 let head_len = indent.len() + marker.len();
277
278 // The continuation the carried words land after. Same rule the
279 // operator uses, so a line broken by typing and the same line broken
280 // by `gq` agree.
281 let continuation = paragraph_prefix(&[line], cfg).rest;
282
283 // Candidate break points: the start of each whitespace run that has
284 // real content before it on this line. Scanning forward and keeping
285 // the LAST one that still fits is the greedy fill, one line at a
286 // time.
287 let mut best: Option<(usize, usize)> = None;
288 let mut seen_word = false;
289 let mut i = head_len;
290 let bytes = line.as_bytes();
291 while i < cursor_byte {
292 let c = bytes[i];
293 if c == b' ' || c == b'\t' {
294 if seen_word {
295 let run_start = i;
296 let mut j = i;
297 while j < line.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
298 j += 1;
299 }
300 if display_width(&line[..run_start]) <= cfg.textwidth {
301 best = Some((run_start, j));
302 } else {
303 // Past the margin already; later runs are worse.
304 break;
305 }
306 i = j;
307 continue;
308 }
309 } else {
310 seen_word = true;
311 }
312 i += 1;
313 }
314
315 let (start, end) = best?;
316 Some(AutoWrapBreak {
317 start,
318 end,
319 replacement: format!("\n{continuation}"),
320 })
321}
322
323/// Whether `line` reads as a comment, by its leading marker alone.
324///
325/// **Lexical on purpose** (§9). A tree-sitter query would also know that
326/// a `//` inside a string literal is not a comment, and would put a
327/// parse on the typing path — which paramount #1 does not allow for
328/// accuracy that costs a frame. The inaccuracy is a comment marker
329/// inside a string, which is rare, and its consequence is one wrapped
330/// line the user can undo.
331pub fn line_is_comment(line: &str, cfg: ReflowConfig<'_>) -> bool {
332 let indent = indent_of(line);
333 !marker_of(&line[indent.len()..], cfg.line_comment).is_empty()
334}
335
336// ---- prefix analysis (§4.2) ----
337
338/// The leading whitespace of `line`.
339fn indent_of(line: &str) -> &str {
340 let end = line
341 .find(|c: char| !c.is_whitespace())
342 .unwrap_or(line.len());
343 &line[..end]
344}
345
346/// The comment-marker run at the start of `line`'s content, if any.
347///
348/// **Read from the line, not from the language table.** `CommentSyntax`
349/// gives `//` for Rust, but Rust comments are written `///` and `//!`;
350/// reflowing a `//!` block with `//` as the leader would rewrite
351/// continuation lines as `// text`, silently turning an inner doc
352/// comment into an outer one.
353///
354/// So the marker is the longest prefix built from the leader's own
355/// characters — `///`, `//!` (`!` is admitted because `/` and `!` are
356/// both leader-ish for the doc forms every C-family language uses), `##`
357/// for `#` languages, `---` for Lua. A block whose lines disagree
358/// degrades through [`paragraph_prefix`]'s longest-common-prefix rule.
359fn marker_of<'a>(content: &'a str, leader: Option<&str>) -> &'a str {
360 let Some(leader) = leader else { return "" };
361 if !content.starts_with(leader) {
362 return "";
363 }
364 let first = leader.chars().next().unwrap_or('\0');
365 let end = content
366 .find(|c: char| c != first && c != '!')
367 .unwrap_or(content.len());
368 &content[..end]
369}
370
371/// `indent + marker` for one line, without the space that follows.
372fn line_prefix(line: &str, cfg: ReflowConfig<'_>) -> String {
373 let indent = indent_of(line);
374 let marker = marker_of(&line[indent.len()..], cfg.line_comment);
375 format!("{indent}{marker}")
376}
377
378/// A list marker at the start of `content`, if any — `- `, `* `, `+ `,
379/// `1. `, `1) `.
380///
381/// Returns the marker INCLUDING its trailing spaces, because that width
382/// is exactly the hanging indent continuation lines need.
383///
384/// Vim needs `formatoptions+=n` and a `formatlistpat` regex for this;
385/// every modern rewrap does it unconditionally, and org and markdown are
386/// first-class here.
387fn list_marker_of(content: &str) -> &str {
388 let b = content.as_bytes();
389 let mut i = 0usize;
390 if matches!(b.first(), Some(b'-' | b'*' | b'+')) {
391 i = 1;
392 } else {
393 while i < b.len() && b[i].is_ascii_digit() {
394 i += 1;
395 }
396 if i == 0 || !matches!(b.get(i), Some(b'.' | b')')) {
397 return "";
398 }
399 i += 1;
400 }
401 // A marker must be followed by whitespace and then something. `-`
402 // alone is a lone dash, and `---` is a horizontal rule, not a
403 // bullet.
404 let after = i;
405 while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
406 i += 1;
407 }
408 if i == after || i == b.len() {
409 return "";
410 }
411 &content[..i]
412}
413
414/// One line decomposed into the four things reflow cares about.
415struct LineParts<'a> {
416 indent: &'a str,
417 marker: &'a str,
418 /// Whitespace between the comment marker and the text.
419 gap: &'a str,
420 list: &'a str,
421}
422
423impl<'a> LineParts<'a> {
424 fn of(line: &'a str, cfg: ReflowConfig<'_>) -> Self {
425 let indent = indent_of(line);
426 let after_indent = &line[indent.len()..];
427 let marker = marker_of(after_indent, cfg.line_comment);
428 let after_marker = &after_indent[marker.len()..];
429 let gap_end = after_marker
430 .find(|c: char| c != ' ' && c != '\t')
431 .unwrap_or(after_marker.len());
432 // Only separate a gap when there IS a marker; otherwise the
433 // leading whitespace is the indent and has already been taken.
434 let gap = if marker.is_empty() {
435 ""
436 } else {
437 &after_marker[..gap_end]
438 };
439 let content = &after_marker[gap.len()..];
440 LineParts {
441 indent,
442 marker,
443 gap,
444 list: list_marker_of(content),
445 }
446 }
447
448 /// What the paragraph's FIRST output line starts with.
449 fn first_prefix(&self) -> String {
450 format!("{}{}{}{}", self.indent, self.marker, self.gap, self.list)
451 }
452
453 /// What every SUBSEQUENT output line starts with — the hanging
454 /// indent: the marker column blanked out so continuation text lines
455 /// up under the item's text rather than under its bullet.
456 fn rest_prefix(&self) -> String {
457 format!(
458 "{}{}{}{}",
459 self.indent,
460 self.marker,
461 self.gap,
462 " ".repeat(display_width(self.list))
463 )
464 }
465
466 /// Whether `next` continues the paragraph this line opened.
467 ///
468 /// Requires the same comment marker, no list marker of its own (a
469 /// bullet always starts a new item), and an indent that is either
470 /// the head's or the head's hanging column.
471 fn continues(&self, next: &str, cfg: ReflowConfig<'_>) -> bool {
472 let n = LineParts::of(next, cfg);
473 if n.marker != self.marker || !n.list.is_empty() {
474 return false;
475 }
476 if n.indent == self.indent {
477 return true;
478 }
479 // A wrapped list item's continuation is indented to the text
480 // column. Only accept that when the head actually opened a list;
481 // otherwise a change of indent is a new paragraph, as before.
482 !self.list.is_empty()
483 && display_width(n.indent) == display_width(self.indent) + display_width(self.list)
484 }
485}
486
487/// A markdown or org fence delimiter — a line whose breaks are content
488/// rather than formatting (§4.5).
489///
490/// Lexical, deliberately: the operator has to work on a buffer with no
491/// parse, and this runs inside it.
492fn is_fence_delimiter(line: &str) -> bool {
493 let t = line.trim_start();
494 if t.starts_with("```") || t.starts_with("~~~") {
495 return true;
496 }
497 let lower = t.to_ascii_lowercase();
498 lower.starts_with("#+begin_") || lower.starts_with("#+end_")
499}
500
501/// A line is a paragraph separator when it is blank, or when it is a
502/// comment leader with no text after it.
503///
504/// The second case is what makes a multi-paragraph doc comment survive
505/// `gqaC`: a bare `///` between two prose runs is the comment
506/// equivalent of a blank line, and treating it as content would weld the
507/// paragraphs together.
508fn is_separator(line: &str, cfg: ReflowConfig<'_>) -> bool {
509 let t = line.trim();
510 if t.is_empty() {
511 return true;
512 }
513 let marker = marker_of(t, cfg.line_comment);
514 !marker.is_empty() && t[marker.len()..].trim().is_empty()
515}
516
517/// The prefix a paragraph's output lines carry.
518///
519/// The comment part is the **longest common prefix** of its lines'
520/// `indent + marker`, which is the rule that makes `///` and `//!`
521/// blocks come back as themselves, handles `#` / `##` with no special
522/// case, and degrades a mixed block to the shared part rather than to a
523/// guess.
524///
525/// The list part comes from the FIRST line only — that is what a hanging
526/// indent means.
527fn paragraph_prefix(lines: &[&str], cfg: ReflowConfig<'_>) -> Prefix {
528 let Some(head) = lines.first().map(|l| LineParts::of(l, cfg)) else {
529 return Prefix::default();
530 };
531 let mut common: Option<String> = None;
532 for l in lines {
533 let p = line_prefix(l, cfg);
534 common = Some(match common {
535 None => p,
536 Some(c) => longest_common_prefix(&c, &p).to_string(),
537 });
538 }
539 let shared = common.unwrap_or_default();
540 // When the lines disagree about their marker, the shared part is
541 // shorter than the head's — fall back to it uniformly rather than
542 // emitting a hanging indent computed from a marker not every line
543 // has.
544 if shared != format!("{}{}", head.indent, head.marker) {
545 let mut base = shared;
546 let indent = lines.first().map(|l| indent_of(l)).unwrap_or("");
547 if base.len() > indent.len() {
548 base.push(' ');
549 }
550 return Prefix::uniform(base);
551 }
552 Prefix {
553 first: head.first_prefix(),
554 rest: head.rest_prefix(),
555 }
556}
557
558fn longest_common_prefix<'a>(a: &'a str, b: &str) -> &'a str {
559 let n = a
560 .char_indices()
561 .zip(b.chars())
562 .take_while(|((_, ca), cb)| ca == cb)
563 .map(|((i, ca), _)| i + ca.len_utf8())
564 .last()
565 .unwrap_or(0);
566 &a[..n]
567}
568
569/// Strip `prefix` from `line` when present, else strip whatever leading
570/// whitespace and marker the line does have.
571///
572/// The fallback matters for the degraded case: a `//` paragraph prefix
573/// computed over a block containing one `///` line must not leave the
574/// third slash in the body text.
575fn strip_known_prefix<'a>(line: &'a str, prefix: &str) -> &'a str {
576 if let Some(rest) = line.strip_prefix(prefix) {
577 return rest;
578 }
579 let t = line.trim_start();
580 let p = prefix.trim();
581 if !p.is_empty()
582 && let Some(rest) = t.strip_prefix(p)
583 {
584 return rest;
585 }
586 t
587}
588
589#[cfg(test)]
590mod tests {
591 #![allow(clippy::unwrap_used)]
592 use super::*;
593
594 fn cfg(textwidth: usize, leader: Option<&str>) -> ReflowConfig<'_> {
595 ReflowConfig {
596 textwidth,
597 line_comment: leader,
598 }
599 }
600
601 fn reflow(lines: &[&str], width: usize, leader: Option<&str>) -> Vec<String> {
602 reflow_range(lines, cfg(width, leader))
603 }
604
605 #[test]
606 fn prose_fills_greedily_to_the_margin() {
607 let out = reflow(&["aaa bbb ccc ddd eee fff"], 11, None);
608 assert_eq!(out, vec!["aaa bbb ccc", "ddd eee fff"]);
609 for l in &out {
610 assert!(display_width(l) <= 11, "{l:?} overflows");
611 }
612 }
613
614 #[test]
615 fn short_input_is_joined_not_only_split() {
616 let out = reflow(&["aaa", "bbb", "ccc"], 20, None);
617 assert_eq!(out, vec!["aaa bbb ccc"]);
618 }
619
620 #[test]
621 fn a_blank_line_separates_and_survives() {
622 let out = reflow(&["aaa bbb", "", "ccc ddd"], 20, None);
623 assert_eq!(out, vec!["aaa bbb", "", "ccc ddd"]);
624 }
625
626 #[test]
627 fn indentation_is_preserved_on_every_output_line() {
628 let out = reflow(&[" aaa bbb ccc ddd"], 12, None);
629 assert_eq!(out, vec![" aaa bbb", " ccc ddd"]);
630 }
631
632 /// The case the longest-common-prefix rule exists for. Deriving the
633 /// leader from `CommentSyntax` (`//`) would rewrite these as `//`
634 /// lines and silently turn an inner doc comment into an outer one.
635 #[test]
636 fn inner_and_outer_doc_comments_keep_their_own_marker() {
637 let inner = reflow(&["//! aaa bbb ccc ddd"], 12, Some("//"));
638 assert_eq!(inner, vec!["//! aaa bbb", "//! ccc ddd"]);
639
640 let outer = reflow(&["/// aaa bbb ccc ddd"], 12, Some("//"));
641 assert_eq!(outer, vec!["/// aaa bbb", "/// ccc ddd"]);
642
643 let plain = reflow(&["// aaa bbb ccc ddd"], 11, Some("//"));
644 assert_eq!(plain, vec!["// aaa bbb", "// ccc ddd"]);
645 }
646
647 #[test]
648 fn hash_languages_keep_their_marker_run() {
649 assert_eq!(
650 reflow(&["## aaa bbb ccc ddd"], 11, Some("#")),
651 vec!["## aaa bbb", "## ccc ddd"]
652 );
653 assert_eq!(
654 reflow(&["# aaa bbb ccc ddd"], 10, Some("#")),
655 vec!["# aaa bbb", "# ccc ddd"]
656 );
657 }
658
659 /// A bare `///` between two prose runs is the comment equivalent of
660 /// a blank line. Treating it as content welds the paragraphs, which
661 /// is what `gqaC` on a real doc comment would do wrong.
662 #[test]
663 fn a_leader_only_line_separates_paragraphs_inside_a_comment() {
664 let out = reflow(
665 &["/// aaa bbb ccc", "///", "/// ddd eee fff"],
666 11,
667 Some("//"),
668 );
669 assert_eq!(
670 out,
671 vec!["/// aaa bbb", "/// ccc", "///", "/// ddd eee", "/// fff"]
672 );
673 }
674
675 #[test]
676 fn a_change_of_marker_starts_a_new_paragraph() {
677 let out = reflow(&["/// aaa", "// bbb"], 40, Some("//"));
678 assert_eq!(out, vec!["/// aaa", "// bbb"]);
679 }
680
681 #[test]
682 fn a_change_of_indent_starts_a_new_paragraph() {
683 let out = reflow(&["aaa", " bbb"], 40, None);
684 assert_eq!(out, vec!["aaa", " bbb"]);
685 }
686
687 /// Never hard-split a word. A long URL in a comment stays one token
688 /// and overflows, which every editor in the field agrees on.
689 #[test]
690 fn a_word_longer_than_the_margin_overflows_rather_than_splitting() {
691 let long = "https://example.com/a/very/long/path/that/exceeds/the/margin";
692 let out = reflow(&[&format!("aa {long} bb")], 20, None);
693 assert_eq!(out, vec!["aa", long, "bb"]);
694 assert!(out.iter().any(|l| display_width(l) > 20));
695 }
696
697 /// Columns, not chars: a CJK glyph is two columns wide, so a line of
698 /// them fits half as many.
699 #[test]
700 fn width_is_measured_in_columns_not_characters() {
701 assert_eq!(display_width("日本語"), 6);
702 let out = reflow(&["日本 語学 練習"], 5, None);
703 assert_eq!(out, vec!["日本", "語学", "練習"]);
704 }
705
706 /// §10: no break point can exist, so greedy filling would emit one
707 /// word per line indefinitely. Leaving it alone beats mangling it.
708 #[test]
709 fn a_prefix_wider_than_the_margin_leaves_the_paragraph_untouched() {
710 let input = [" //// aaa bbb ccc"];
711 let out = reflow(&input, 4, Some("//"));
712 assert_eq!(out, vec![input[0].to_string()]);
713 }
714
715 #[test]
716 fn an_empty_range_is_an_empty_result() {
717 assert!(reflow(&[], 80, None).is_empty());
718 }
719
720 #[test]
721 fn a_paragraph_that_already_fits_comes_back_unchanged() {
722 let input = ["aaa bbb ccc"];
723 assert_eq!(reflow(&input, 80, None), vec!["aaa bbb ccc".to_string()]);
724 }
725
726 /// Trailing whitespace goes and interior runs collapse — the
727 /// normalisation vim's `gq` also does. Asserted because it is the
728 /// difference between "reflow is a no-op here" and "reflow made an
729 /// invisible edit".
730 #[test]
731 fn interior_and_trailing_whitespace_are_normalised() {
732 assert_eq!(
733 reflow(&["aaa bbb ", "ccc"], 40, None),
734 vec!["aaa bbb ccc"]
735 );
736 }
737
738 #[test]
739 fn a_line_of_only_a_leader_is_not_emptied() {
740 assert_eq!(reflow(&["///"], 40, Some("//")), vec!["///"]);
741 assert_eq!(reflow(&[" "], 40, None), vec![" "]);
742 }
743
744 /// Reflow moves line breaks and nothing else. A `//` inside a string
745 /// literal is not a comment, but the engine has no tree and cannot
746 /// know that — so this pins the blast radius: the operator only ever
747 /// sees the range the user selected (RF.2), and within it the worst
748 /// case is a prefix guessed from a lexical marker.
749 #[test]
750 fn only_line_breaks_and_interior_spacing_change() {
751 let out = reflow(&["let x = 1; let y = 2;"], 12, None);
752 assert_eq!(out, vec!["let x = 1;", "let y = 2;"]);
753 // Every word survives, in order.
754 let words: Vec<&str> = out.iter().flat_map(|l| l.split_whitespace()).collect();
755 assert_eq!(words, vec!["let", "x", "=", "1;", "let", "y", "=", "2;"]);
756 }
757
758 // ---- RF.4: lists, hanging indent, fences ----
759
760 /// The hanging indent: continuation lines align under the item's
761 /// TEXT, not under its bullet. Wrapping to column 0 would make the
762 /// second line read as a new paragraph.
763 #[test]
764 fn a_bullet_wraps_to_its_text_column() {
765 let out = reflow(&["- aaa bbb ccc ddd"], 9, None);
766 assert_eq!(out, vec!["- aaa bbb", " ccc ddd"]);
767 }
768
769 #[test]
770 fn ordered_list_markers_hang_by_their_own_width() {
771 assert_eq!(
772 reflow(&["1. aaa bbb ccc"], 10, None),
773 vec!["1. aaa bbb", " ccc"]
774 );
775 assert_eq!(
776 reflow(&["10) aaa bbb ccc"], 11, None),
777 vec!["10) aaa bbb", " ccc"]
778 );
779 }
780
781 /// A second bullet is a second item, not a continuation — otherwise
782 /// `gqap` over a list welds every item into one paragraph.
783 #[test]
784 fn a_second_bullet_starts_a_new_item() {
785 let out = reflow(&["- aaa", "- bbb"], 40, None);
786 assert_eq!(out, vec!["- aaa", "- bbb"]);
787 }
788
789 /// An already-wrapped item is re-joined and re-filled as one item,
790 /// which is what makes `gq` idempotent on a list.
791 #[test]
792 fn an_already_wrapped_item_rejoins_as_one() {
793 let out = reflow(&["- aaa bbb", " ccc ddd"], 40, None);
794 assert_eq!(out, vec!["- aaa bbb ccc ddd"]);
795 }
796
797 #[test]
798 fn a_bullet_inside_a_comment_block_keeps_both_prefixes() {
799 let out = reflow(&["/// - aaa bbb ccc"], 13, Some("//"));
800 assert_eq!(out, vec!["/// - aaa bbb", "/// ccc"]);
801 }
802
803 /// `-` alone is a dash and `---` is a horizontal rule; neither is a
804 /// bullet. A marker needs whitespace and then text after it.
805 #[test]
806 fn a_lone_dash_or_a_rule_is_not_a_list_marker() {
807 assert_eq!(list_marker_of("---"), "");
808 assert_eq!(list_marker_of("-"), "");
809 assert_eq!(list_marker_of("- "), "");
810 assert_eq!(list_marker_of("- x"), "- ");
811 assert_eq!(list_marker_of("1. x"), "1. ");
812 assert_eq!(list_marker_of("1.x"), "", "a marker needs a space");
813 assert_eq!(list_marker_of("word"), "");
814 }
815
816 /// §4.5: inside a fence the line breaks ARE the content. Reflowing
817 /// them would corrupt a code sample.
818 #[test]
819 fn a_fenced_block_is_carried_through_verbatim() {
820 let input = [
821 "aaa bbb ccc",
822 "```",
823 "fn main() { let x = 1; }",
824 "let y = 2;",
825 "```",
826 "ddd eee fff",
827 ];
828 let out = reflow(&input, 7, None);
829 assert_eq!(
830 out,
831 vec![
832 "aaa bbb",
833 "ccc",
834 "```",
835 "fn main() { let x = 1; }",
836 "let y = 2;",
837 "```",
838 "ddd eee",
839 "fff",
840 ],
841 "prose either side reflows; the fenced body does not"
842 );
843 }
844
845 #[test]
846 fn org_blocks_are_fences_too() {
847 let input = ["#+begin_src rust", "let x = 1; let y = 2;", "#+end_src"];
848 assert_eq!(
849 reflow(&input, 7, None),
850 input.iter().map(|s| s.to_string()).collect::<Vec<_>>()
851 );
852 }
853
854 /// An unterminated fence swallows the rest of the range rather than
855 /// reflowing content the user thinks is code. Degrading toward "do
856 /// nothing" is the right direction for an operator that rewrites.
857 #[test]
858 fn an_unclosed_fence_protects_everything_after_it() {
859 let input = ["```", "let x = 1; let y = 2;"];
860 assert_eq!(
861 reflow(&input, 7, None),
862 input.iter().map(|s| s.to_string()).collect::<Vec<_>>()
863 );
864 }
865
866 // ---- RF.3: the auto-wrap break point ----
867
868 fn brk(line: &str, width: usize, leader: Option<&str>) -> Option<AutoWrapBreak> {
869 auto_wrap_break(line, line.len(), cfg(width, leader))
870 }
871
872 /// The break replaces the whitespace run, so the space does not
873 /// become trailing whitespace on the line above.
874 #[test]
875 fn auto_wrap_breaks_at_the_last_space_that_fits() {
876 let b = brk("aaa bbb ccc", 7, None).expect("must break");
877 assert_eq!(&"aaa bbb ccc"[b.start..b.end], " ");
878 assert_eq!(b.start, 7, "the space after `bbb` is the last that fits");
879 assert_eq!(b.replacement, "\n");
880 }
881
882 #[test]
883 fn no_break_until_the_cursor_passes_the_margin() {
884 assert!(brk("aaa bbb", 80, None).is_none());
885 assert!(brk("", 80, None).is_none());
886 }
887
888 /// One long word overflows rather than being split — the same rule
889 /// the operator follows, so typing and `gq` cannot disagree.
890 #[test]
891 fn a_single_long_word_does_not_break() {
892 assert!(brk("aaaaaaaaaaaaaaaaaaaa", 5, None).is_none());
893 // …and neither does a leader followed by one long word.
894 assert!(brk("// aaaaaaaaaaaaaaaaaaaa", 5, Some("//")).is_none());
895 }
896
897 /// The carried remainder gets the comment leader, or a doc comment
898 /// silently turns into code on the next line.
899 #[test]
900 fn the_continuation_carries_the_comment_leader() {
901 let line = "/// aaa bbb ccc";
902 let b = brk(line, 11, Some("//")).expect("must break");
903 assert_eq!(b.replacement, "\n/// ");
904 assert_eq!(&line[b.start..b.end], " ");
905 }
906
907 #[test]
908 fn the_continuation_carries_indentation() {
909 let line = " aaa bbb ccc";
910 let b = brk(line, 11, None).expect("must break");
911 assert_eq!(b.replacement, "\n ");
912 }
913
914 /// Never break inside the marker itself — `///` split across a
915 /// newline would leave `//` and a stray `/`.
916 #[test]
917 fn the_marker_is_never_a_break_point() {
918 let line = "///aaa bbb";
919 let b = brk(line, 6, Some("//")).expect("must break");
920 assert!(
921 b.start >= 3,
922 "break at {} is inside the `///` marker",
923 b.start
924 );
925 }
926
927 #[test]
928 fn line_is_comment_reads_the_leading_marker_only() {
929 let c = cfg(80, Some("//"));
930 assert!(line_is_comment(" // hi", c));
931 assert!(line_is_comment("/// hi", c));
932 assert!(!line_is_comment("let x = 1; // hi", c));
933 assert!(!line_is_comment("plain", c));
934 // No leader declared (markdown, plain text): nothing is a comment.
935 assert!(!line_is_comment("// hi", cfg(80, None)));
936 }
937
938 #[test]
939 fn paragraphs_reports_fillable_and_verbatim_runs_in_order() {
940 let lines = ["aaa", "", "bbb"];
941 let ps = paragraphs(&lines, cfg(40, None));
942 assert_eq!(ps.len(), 3);
943 assert!(ps[0].fillable && ps[0].start == 0 && ps[0].end == 1);
944 assert!(!ps[1].fillable, "the blank run is carried, not filled");
945 assert!(ps[2].fillable && ps[2].start == 2 && ps[2].end == 3);
946 }
947}