Skip to main content

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}