Skip to main content

vix_textops/
lib.rs

1//! Small pure text transforms used by Edit/Tools actions.
2//!
3//! Two shapes live here: whole-text transforms (`&str -> String`: line-ending
4//! conversion, blank-line squeezing, ROT13, hard wrap) and cursor-relative
5//! rewrites (`(&str, usize) -> Option<(String, usize)>`: increment number,
6//! smart toggle, transpose characters/words/lines/sentences/paragraphs/
7//! sections, wrap the paragraph at the cursor). The host applies the former via
8//! `App::transform_selection_or_buffer` and the latter via
9//! `App::rewrite_at_cursor`; everything here is unit-tested without a terminal.
10
11#![warn(clippy::pedantic)]
12#![forbid(unsafe_code)]
13#![deny(missing_docs)]
14
15/// Convert all line endings to LF (`\n`), dropping any `\r`.
16#[must_use]
17pub fn to_lf(text: &str) -> String {
18    text.replace("\r\n", "\n").replace('\r', "\n")
19}
20
21/// Convert all line endings to CRLF (`\r\n`). Normalizes to LF first so mixed
22/// input doesn't produce `\r\r\n`.
23#[must_use]
24pub fn to_crlf(text: &str) -> String {
25    to_lf(text).replace('\n', "\r\n")
26}
27
28/// Collapse runs of two or more blank (empty or whitespace-only) lines into a
29/// single empty line. A trailing newline is preserved.
30#[must_use]
31pub fn squeeze_blank_lines(text: &str) -> String {
32    let had_trailing_newline = text.ends_with('\n');
33    let mut out: Vec<&str> = Vec::new();
34    let mut prev_blank = false;
35    for line in text.split('\n') {
36        let blank = line.trim().is_empty();
37        if blank && prev_blank {
38            continue;
39        }
40        out.push(line);
41        prev_blank = blank;
42    }
43    let mut joined = out.join("\n");
44    if had_trailing_newline && !joined.ends_with('\n') {
45        joined.push('\n');
46    }
47    joined
48}
49
50/// ROT13: rotate ASCII letters by 13 (its own inverse); other chars unchanged.
51#[must_use]
52pub fn rot13(text: &str) -> String {
53    text.chars()
54        .map(|c| match c {
55            'a'..='z' => (b'a' + (c as u8 - b'a' + 13) % 26) as char,
56            'A'..='Z' => (b'A' + (c as u8 - b'A' + 13) % 26) as char,
57            _ => c,
58        })
59        .collect()
60}
61
62/// Increment (or decrement, `delta = -1`) the integer at or immediately after the
63/// cursor char offset `cursor` in `text`. Returns the rewritten text and the new
64/// cursor offset (kept at the number's start), or `None` if no digit is found on
65/// the cursor's line at/after the cursor. Handles an optional leading `-`.
66#[must_use]
67pub fn bump_number_at(text: &str, cursor: usize, delta: i64) -> Option<(String, usize)> {
68    let chars: Vec<char> = text.chars().collect();
69    let n = chars.len();
70    // Search from the cursor to the end of the current line for a digit.
71    let mut i = cursor.min(n);
72    while i < n && chars[i] != '\n' && !chars[i].is_ascii_digit() {
73        i += 1;
74    }
75    if i >= n || chars[i] == '\n' {
76        return None;
77    }
78    // Expand left over digits, then include a leading '-' if present.
79    let mut start = i;
80    while start > 0 && chars[start - 1].is_ascii_digit() {
81        start -= 1;
82    }
83    if start > 0 && chars[start - 1] == '-' {
84        start -= 1;
85    }
86    let mut end = i;
87    while end < n && chars[end].is_ascii_digit() {
88        end += 1;
89    }
90    let token: String = chars[start..end].iter().collect();
91    let value: i64 = token.parse().ok()?;
92    let bumped = value.saturating_add(delta).to_string();
93    let mut out: String = chars[..start].iter().collect();
94    out.push_str(&bumped);
95    out.extend(chars[end..].iter());
96    Some((out, start))
97}
98
99/// Transpose the two characters around char offset `cursor` (Emacs `C-t`): swap
100/// the char before the cursor with the one at it, advancing the cursor. At the
101/// end of a line/buffer, swaps the last two characters. Never crosses newlines.
102/// Returns the rewritten text and new cursor, or `None` if there is no pair.
103#[must_use]
104pub fn transpose_chars_at(text: &str, cursor: usize) -> Option<(String, usize)> {
105    let mut chars: Vec<char> = text.chars().collect();
106    let n = chars.len();
107    // The left index of the pair to swap.
108    let i = if cursor >= 1 && cursor < n && chars[cursor] != '\n' {
109        cursor - 1
110    } else if cursor >= 2 {
111        cursor - 2
112    } else {
113        return None;
114    };
115    if i + 1 >= n || chars[i] == '\n' || chars[i + 1] == '\n' {
116        return None;
117    }
118    chars.swap(i, i + 1);
119    Some((chars.iter().collect(), (i + 2).min(n)))
120}
121
122/// Transpose the word before the cursor with the word at/after it (Emacs `M-t`),
123/// preserving the separator between them and leaving the cursor after the moved
124/// pair. Returns `None` if two words can't be found.
125#[must_use]
126pub fn transpose_words_at(text: &str, cursor: usize) -> Option<(String, usize)> {
127    let chars: Vec<char> = text.chars().collect();
128    let n = chars.len();
129    let is_word = |c: char| c.is_alphanumeric() || c == '_';
130    // Start of the second word: the word containing the cursor, else the next one.
131    let mut b = cursor.min(n);
132    if b < n && is_word(chars[b]) {
133        while b > 0 && is_word(chars[b - 1]) {
134            b -= 1;
135        }
136    } else {
137        while b < n && !is_word(chars[b]) {
138            b += 1;
139        }
140    }
141    if b >= n {
142        return None;
143    }
144    let mut b_end = b;
145    while b_end < n && is_word(chars[b_end]) {
146        b_end += 1;
147    }
148    // The first word: the word ending before `b`.
149    let mut a_end = b;
150    while a_end > 0 && !is_word(chars[a_end - 1]) {
151        a_end -= 1;
152    }
153    let mut a = a_end;
154    while a > 0 && is_word(chars[a - 1]) {
155        a -= 1;
156    }
157    if a == a_end {
158        return None; // no preceding word
159    }
160    let word1: String = chars[a..a_end].iter().collect();
161    let sep: String = chars[a_end..b].iter().collect();
162    let word2: String = chars[b..b_end].iter().collect();
163    let mut out: String = chars[..a].iter().collect();
164    out.push_str(&word2);
165    out.push_str(&sep);
166    out.push_str(&word1);
167    out.extend(chars[b_end..].iter());
168    let new_cursor = a + word2.chars().count() + sep.chars().count() + word1.chars().count();
169    Some((out, new_cursor))
170}
171
172/// Char offsets where each sentence begins: the first non-space char, then the
173/// first non-space char after any `.`/`!`/`?` (plus trailing quotes/brackets)
174/// followed by whitespace. Shared by the Go → Sentence navigation and
175/// [`transpose_sentences_at`], so both agree on where a sentence starts.
176#[must_use]
177pub fn sentence_starts(text: &str) -> Vec<usize> {
178    let chars: Vec<char> = text.chars().collect();
179    let n = chars.len();
180    let mut starts = Vec::new();
181    let mut i = 0;
182    while i < n && chars[i].is_whitespace() {
183        i += 1;
184    }
185    if i < n {
186        starts.push(i);
187    }
188    while i < n {
189        if matches!(chars[i], '.' | '!' | '?') {
190            let mut j = i + 1;
191            while j < n && matches!(chars[j], '.' | '!' | '?' | '"' | '\'' | ')' | ']' | '}') {
192                j += 1;
193            }
194            if j < n && chars[j].is_whitespace() {
195                while j < n && chars[j].is_whitespace() {
196                    j += 1;
197                }
198                if j < n {
199                    starts.push(j);
200                }
201                i = j;
202                continue;
203            }
204        }
205        i += 1;
206    }
207    starts.dedup();
208    starts
209}
210
211/// Swap the two units around `cursor`, keeping the text between them (the
212/// separator) in place. `units` are sorted, non-overlapping `(start, end)` char
213/// ranges; the pair is the unit holding the cursor and its predecessor, or the
214/// last two when the cursor sits past every unit. Returns the rewritten text
215/// and the cursor just after the swapped pair, or `None` when there is no pair.
216fn transpose_units_at(
217    text: &str,
218    cursor: usize,
219    units: &[(usize, usize)],
220) -> Option<(String, usize)> {
221    let i = units
222        .iter()
223        .position(|&(s, e)| cursor >= s && cursor <= e)
224        .or_else(|| units.iter().position(|&(s, _)| s > cursor))
225        .unwrap_or(units.len().saturating_sub(1));
226    if i == 0 {
227        return None;
228    }
229    let (a, a_end) = units[i - 1];
230    let (b, b_end) = units[i];
231    let chars: Vec<char> = text.chars().collect();
232    let first: String = chars[a..a_end].iter().collect();
233    let sep: String = chars[a_end..b].iter().collect();
234    let second: String = chars[b..b_end].iter().collect();
235    let mut out: String = chars[..a].iter().collect();
236    out.push_str(&second);
237    out.push_str(&sep);
238    out.push_str(&first);
239    out.extend(chars[b_end..].iter());
240    Some((out, b_end))
241}
242
243/// The `(start, end)` char range of every line's content, the newline
244/// excluded. A trailing newline does not add an empty final line. Public for
245/// `vix-modal`'s line-based motions (`j`/`k`/`0`/`^`/`$`/`gg`/`G`), which need
246/// the same line boundaries the transpose commands already use.
247#[must_use]
248pub fn line_ranges(chars: &[char]) -> Vec<(usize, usize)> {
249    let mut ranges = Vec::new();
250    let mut start = 0;
251    for (i, c) in chars.iter().enumerate() {
252        if *c == '\n' {
253            ranges.push((start, i));
254            start = i + 1;
255        }
256    }
257    if start < chars.len() || ranges.is_empty() {
258        ranges.push((start, chars.len()));
259    }
260    ranges
261}
262
263/// Whether the char range `r` is blank (empty or whitespace-only).
264fn range_is_blank(chars: &[char], r: (usize, usize)) -> bool {
265    chars[r.0..r.1].iter().all(|c| c.is_whitespace())
266}
267
268/// Group consecutive lines into units, starting a new unit after every line for
269/// which `is_break` holds; break lines and blank edges are left out of the units
270/// (so they stay put as separators when two units are swapped).
271fn line_group_units(
272    chars: &[char],
273    rows: &[(usize, usize)],
274    is_break: impl Fn(usize) -> bool,
275) -> Vec<(usize, usize)> {
276    let mut units: Vec<(usize, usize)> = Vec::new();
277    let mut group: Vec<(usize, usize)> = Vec::new();
278    let flush = |group: &mut Vec<(usize, usize)>, units: &mut Vec<(usize, usize)>| {
279        while group.last().is_some_and(|&r| range_is_blank(chars, r)) {
280            group.pop();
281        }
282        while group.first().is_some_and(|&r| range_is_blank(chars, r)) {
283            group.remove(0);
284        }
285        if let (Some(first), Some(last)) = (group.first(), group.last()) {
286            units.push((first.0, last.1));
287        }
288        group.clear();
289    };
290    for (row, &range) in rows.iter().enumerate() {
291        if is_break(row) {
292            flush(&mut group, &mut units);
293        } else {
294            group.push(range);
295        }
296    }
297    flush(&mut group, &mut units);
298    units
299}
300
301/// Transpose the line before the cursor's line with the cursor's line (Emacs
302/// `C-x C-t`), preserving the newline between them and leaving the cursor at the
303/// end of the pair. `None` on the first line of the buffer.
304#[must_use]
305pub fn transpose_lines_at(text: &str, cursor: usize) -> Option<(String, usize)> {
306    let chars: Vec<char> = text.chars().collect();
307    let units = line_ranges(&chars);
308    transpose_units_at(text, cursor, &units)
309}
310
311/// The `(start, end)` char range of every sentence, trailing whitespace
312/// excluded. Sentences begin where [`sentence_starts`] says they do, so the
313/// transpose and delete commands agree with the Go → Sentence navigation.
314/// Public for `vix-modal`'s `(`/`)` sentence motions
315/// (`crates/vix-modal/spec/index.md`).
316#[must_use]
317pub fn sentence_units(text: &str, chars: &[char]) -> Vec<(usize, usize)> {
318    let starts = sentence_starts(text);
319    starts
320        .iter()
321        .enumerate()
322        .map(|(i, &start)| {
323            let mut end = starts.get(i + 1).copied().unwrap_or(chars.len());
324            while end > start && chars[end - 1].is_whitespace() {
325                end -= 1;
326            }
327            (start, end)
328        })
329        .collect()
330}
331
332/// The `(start, end)` char range of every paragraph: a run of non-blank lines,
333/// as in the Go → Paragraph navigation. Public for `vix-modal`'s `{`/`}`
334/// paragraph motions (`crates/vix-modal/spec/index.md`).
335#[must_use]
336pub fn paragraph_units(chars: &[char]) -> Vec<(usize, usize)> {
337    let rows = line_ranges(chars);
338    line_group_units(chars, &rows, |row| range_is_blank(chars, rows[row]))
339}
340
341/// The `(start, end)` char range of every section: a run of lines delimited by
342/// two or more blank lines, as in the Go → Section navigation.
343fn section_units(chars: &[char]) -> Vec<(usize, usize)> {
344    let rows = line_ranges(chars);
345    let blank = |row: usize| range_is_blank(chars, rows[row]);
346    line_group_units(chars, &rows, |row| {
347        blank(row) && ((row > 0 && blank(row - 1)) || (row + 1 < rows.len() && blank(row + 1)))
348    })
349}
350
351/// Transpose the sentence before the cursor with the sentence at/after it
352/// (Emacs `M-x transpose-sentences`), preserving the whitespace between them.
353/// Sentences are split as in the Go → Sentence navigation
354/// ([`sentence_starts`]). `None` when there is no pair.
355#[must_use]
356pub fn transpose_sentences_at(text: &str, cursor: usize) -> Option<(String, usize)> {
357    let chars: Vec<char> = text.chars().collect();
358    let units = sentence_units(text, &chars);
359    transpose_units_at(text, cursor, &units)
360}
361
362/// Transpose the paragraph before the cursor with the paragraph at/after it
363/// (Emacs `M-x transpose-paragraphs`), preserving the blank lines between them.
364/// Paragraphs are runs of non-blank lines, as in the Go → Paragraph navigation.
365/// `None` when there is no pair.
366#[must_use]
367pub fn transpose_paragraphs_at(text: &str, cursor: usize) -> Option<(String, usize)> {
368    let chars: Vec<char> = text.chars().collect();
369    let units = paragraph_units(&chars);
370    transpose_units_at(text, cursor, &units)
371}
372
373/// Transpose the section before the cursor with the section at/after it,
374/// preserving the break between them. Sections are delimited by a run of two or
375/// more blank lines, as in the Go → Section navigation. `None` when there is no
376/// pair.
377#[must_use]
378pub fn transpose_sections_at(text: &str, cursor: usize) -> Option<(String, usize)> {
379    let chars: Vec<char> = text.chars().collect();
380    let units = section_units(&chars);
381    transpose_units_at(text, cursor, &units)
382}
383
384/// The `(start, end)` char range of every word: a run of alphanumeric or `_`
385/// characters, as used by the word motions. Public for `vix-modal`'s
386/// `w`/`b`/`e` word motions (`crates/vix-modal/spec/index.md`) — same
387/// lowercase-only `word` definition the spec's v1 explicitly picks (no `WORD`
388/// distinction), so punctuation is a separator, not its own word, unlike real
389/// Vim's `word`; documented there as a deliberate v1 scope decision.
390#[must_use]
391pub fn word_units(chars: &[char]) -> Vec<(usize, usize)> {
392    let is_word = |c: char| c.is_alphanumeric() || c == '_';
393    let mut units = Vec::new();
394    let mut i = 0;
395    while i < chars.len() {
396        if is_word(chars[i]) {
397            let start = i;
398            while i < chars.len() && is_word(chars[i]) {
399                i += 1;
400            }
401            units.push((start, i));
402        } else {
403            i += 1;
404        }
405    }
406    units
407}
408
409/// Delete the unit holding `cursor` — or the next one, when the cursor sits
410/// between units — together with the separator that follows it, so the
411/// surrounding text closes up. The separator before the unit is taken instead
412/// when nothing follows (the last unit, or a line break `keep_lines` protects).
413/// `keep_lines` bars the separator from crossing a newline, so deleting a word
414/// or a sentence never joins two lines; line-based units (paragraphs, sections)
415/// pass `false` and swallow the blank lines between them. `units` are sorted,
416/// non-overlapping `(start, end)` char ranges. Returns the rewritten text and
417/// the cursor at the hole left behind, or `None` when there is no unit.
418fn delete_unit_at(
419    text: &str,
420    cursor: usize,
421    units: &[(usize, usize)],
422    keep_lines: bool,
423) -> Option<(String, usize)> {
424    let i = units
425        .iter()
426        .position(|&(s, e)| cursor >= s && cursor <= e)
427        .or_else(|| units.iter().position(|&(s, _)| s > cursor))
428        .unwrap_or(units.len().saturating_sub(1));
429    let &(start, end) = units.get(i)?;
430    let chars: Vec<char> = text.chars().collect();
431    let separator = |c: char| c.is_whitespace() && !(keep_lines && c == '\n');
432    let mut from = start;
433    let mut to = end;
434    if let Some(&(next, _)) = units.get(i + 1) {
435        while to < next && separator(chars[to]) {
436            to += 1;
437        }
438    }
439    if to == end {
440        let prev_end = if i == 0 { 0 } else { units[i - 1].1 };
441        while from > prev_end && separator(chars[from - 1]) {
442            from -= 1;
443        }
444    }
445    if from == start && to == end {
446        // A lone unit: take whatever whitespace trails it.
447        while to < chars.len() && separator(chars[to]) {
448            to += 1;
449        }
450    }
451    let mut out: String = chars[..from].iter().collect();
452    out.extend(chars[to..].iter());
453    Some((out, from))
454}
455
456/// Delete the character at char offset `cursor` (Emacs `C-d`), leaving the
457/// cursor where it was. `None` at the end of the buffer, where there is nothing
458/// to delete.
459#[must_use]
460pub fn delete_char_at(text: &str, cursor: usize) -> Option<(String, usize)> {
461    let chars: Vec<char> = text.chars().collect();
462    if cursor >= chars.len() {
463        return None;
464    }
465    let mut out: String = chars[..cursor].iter().collect();
466    out.extend(chars[cursor + 1..].iter());
467    Some((out, cursor))
468}
469
470/// Delete the word at the cursor (or the next one, when the cursor is between
471/// words) along with the spacing after it, staying on the line. `None` when the
472/// text holds no word.
473#[must_use]
474pub fn delete_word_at(text: &str, cursor: usize) -> Option<(String, usize)> {
475    let chars: Vec<char> = text.chars().collect();
476    let units = word_units(&chars);
477    delete_unit_at(text, cursor, &units, true)
478}
479
480/// Delete the sentence at the cursor along with the spacing after it, staying on
481/// the line. Sentences are split as in the Go → Sentence navigation
482/// ([`sentence_starts`]). `None` when the text holds no sentence.
483#[must_use]
484pub fn delete_sentence_at(text: &str, cursor: usize) -> Option<(String, usize)> {
485    let chars: Vec<char> = text.chars().collect();
486    let units = sentence_units(text, &chars);
487    delete_unit_at(text, cursor, &units, true)
488}
489
490/// Delete the paragraph at the cursor (a run of non-blank lines) along with the
491/// blank lines that separate it from the next one. `None` when the text holds no
492/// paragraph.
493#[must_use]
494pub fn delete_paragraph_at(text: &str, cursor: usize) -> Option<(String, usize)> {
495    let chars: Vec<char> = text.chars().collect();
496    let units = paragraph_units(&chars);
497    delete_unit_at(text, cursor, &units, false)
498}
499
500/// Delete the section at the cursor (lines delimited by two or more blank lines)
501/// along with the break that separates it from the next one. `None` when the
502/// text holds no section.
503#[must_use]
504pub fn delete_section_at(text: &str, cursor: usize) -> Option<(String, usize)> {
505    let chars: Vec<char> = text.chars().collect();
506    let units = section_units(&chars);
507    delete_unit_at(text, cursor, &units, false)
508}
509
510/// Line leaders a wrapped chunk may repeat on every line (comment and quote
511/// markers). Longest first, so `///` wins over `//`. Bullet markers are handled
512/// separately (see [`bullet_len`]) because each bullet is its own chunk.
513const WRAP_MARKERS: &[&str] = &["///", "//", "#", "--", ";;", ";", ">"];
514
515/// Length in chars of the list bullet starting `line` (after its indentation) —
516/// `-`, `*`, `+`, `1.`, `1)` — including the whitespace after it, or `None` when
517/// the line does not start a list item.
518fn bullet_len(line: &str) -> Option<usize> {
519    let body = line.trim_start();
520    let marker = if body.starts_with(['-', '*', '+']) {
521        1
522    } else {
523        let digits = body.chars().take_while(char::is_ascii_digit).count();
524        if digits == 0 || !matches!(body.chars().nth(digits), Some('.' | ')')) {
525            return None;
526        }
527        digits + 1
528    };
529    let spaces = body.chars().skip(marker).take_while(|c| *c == ' ').count();
530    if spaces == 0 {
531        return None;
532    }
533    Some(marker + spaces)
534}
535
536/// The prefix repeated on every wrapped line of a chunk, plus the marker it was
537/// built from (stripped from each line's words). Falls back to the first line's
538/// indentation when the lines share no marker.
539fn fill_prefix(lines: &[&str]) -> (String, Option<&'static str>) {
540    let first = lines[0];
541    let indent: String = first.chars().take_while(|c| c.is_whitespace()).collect();
542    let rest = &first[indent.len()..];
543    for marker in WRAP_MARKERS {
544        if let Some(after) = rest.strip_prefix(marker)
545            && lines.iter().all(|l| l.trim_start().starts_with(marker))
546        {
547            let spaces: String = after.chars().take_while(|c| *c == ' ').collect();
548            return (format!("{indent}{marker}{spaces}"), Some(marker));
549        }
550    }
551    (indent, None)
552}
553
554/// Greedily fill `lines` (one chunk: no blank lines, one list item at most) into
555/// lines of at most `width` chars, repeating the chunk's prefix. A word longer
556/// than the width still gets its own line rather than being split.
557fn wrap_chunk(lines: &[&str], width: usize) -> Vec<String> {
558    let bullet = bullet_len(lines[0]);
559    let (prefix, marker) = match bullet {
560        Some(_) => (String::new(), None),
561        None => fill_prefix(lines),
562    };
563    let mut words: Vec<&str> = Vec::new();
564    for (i, line) in lines.iter().enumerate() {
565        let mut body = line.trim_start();
566        if let Some(m) = marker {
567            body = body.strip_prefix(m).unwrap_or(body);
568        }
569        if i == 0
570            && let Some(len) = bullet
571        {
572            body = &body[body
573                .char_indices()
574                .nth(len)
575                .map_or(body.len(), |(byte, _)| byte)..];
576        }
577        words.extend(body.split_whitespace());
578    }
579    if words.is_empty() {
580        return lines.iter().map(|l| (*l).to_string()).collect();
581    }
582    // A list item keeps its bullet on the first line and hangs the rest under it.
583    let indent: String = lines[0].chars().take_while(|c| c.is_whitespace()).collect();
584    let (first_prefix, cont_prefix) = match bullet {
585        Some(len) => {
586            let head: String = lines[0].trim_start().chars().take(len).collect();
587            let hang = " ".repeat(indent.chars().count() + len);
588            (format!("{indent}{head}"), hang)
589        }
590        None => (prefix.clone(), prefix),
591    };
592    let mut out: Vec<String> = Vec::new();
593    let mut cur = String::new();
594    for word in words {
595        let lead = if out.is_empty() {
596            &first_prefix
597        } else {
598            &cont_prefix
599        };
600        let room = lead.chars().count() + cur.chars().count() + 1 + word.chars().count() <= width;
601        if cur.is_empty() {
602            cur.push_str(word);
603        } else if room {
604            cur.push(' ');
605            cur.push_str(word);
606        } else {
607            out.push(format!("{lead}{cur}"));
608            cur = word.to_string();
609        }
610    }
611    let lead = if out.is_empty() {
612        &first_prefix
613    } else {
614        &cont_prefix
615    };
616    out.push(format!("{lead}{cur}"));
617    out
618}
619
620/// Hard-wrap (fill) `text` to at most `width` chars per line. Blank lines and
621/// list items separate chunks, and each chunk is refilled on its own: its words
622/// are re-flowed greedily, keeping the chunk's indentation, any comment/quote
623/// marker shared by every line, and a hanging indent under a list bullet.
624/// Widths count chars, not terminal columns.
625#[must_use]
626pub fn wrap(text: &str, width: usize) -> String {
627    let mut out: Vec<String> = Vec::new();
628    let mut chunk: Vec<&str> = Vec::new();
629    for line in text.split('\n') {
630        let blank = line.trim().is_empty();
631        if (blank || bullet_len(line).is_some()) && !chunk.is_empty() {
632            out.extend(wrap_chunk(&chunk, width));
633            chunk.clear();
634        }
635        if blank {
636            out.push(line.to_string());
637        } else {
638            chunk.push(line);
639        }
640    }
641    if !chunk.is_empty() {
642        out.extend(wrap_chunk(&chunk, width));
643    }
644    out.join("\n")
645}
646
647/// Hard-wrap the paragraph around the cursor (the run of non-blank lines holding
648/// it) to `width` chars, leaving the cursor at the end of the rewritten
649/// paragraph. `None` when the cursor is not in a paragraph or the paragraph is
650/// already wrapped.
651#[must_use]
652pub fn wrap_paragraph_at(text: &str, cursor: usize, width: usize) -> Option<(String, usize)> {
653    let chars: Vec<char> = text.chars().collect();
654    let rows = line_ranges(&chars);
655    let units = line_group_units(&chars, &rows, |row| range_is_blank(&chars, rows[row]));
656    let &(start, end) = units
657        .iter()
658        .find(|&&(s, e)| cursor >= s && cursor <= e)
659        .or_else(|| units.iter().find(|&&(s, _)| s > cursor))?;
660    let filled = wrap(&chars[start..end].iter().collect::<String>(), width);
661    if filled == chars[start..end].iter().collect::<String>() {
662        return None;
663    }
664    let mut out: String = chars[..start].iter().collect();
665    out.push_str(&filled);
666    out.extend(chars[end..].iter());
667    Some((out, start + filled.chars().count()))
668}
669
670/// Opposite-value pairs for [`smart_toggle_at`]. Word pairs are matched
671/// whole-word and case-preserved; symbol pairs are matched literally.
672const TOGGLE_WORDS: &[(&str, &str)] = &[
673    ("true", "false"),
674    ("yes", "no"),
675    ("on", "off"),
676    ("enable", "disable"),
677    ("enabled", "disabled"),
678    ("left", "right"),
679    ("up", "down"),
680    ("min", "max"),
681    ("and", "or"),
682];
683const TOGGLE_SYMBOLS: &[(&str, &str)] = &[
684    ("&&", "||"),
685    ("==", "!="),
686    ("<=", ">="),
687    ("<", ">"),
688    ("++", "--"),
689];
690
691/// Toggle the boolean-ish token at char offset `cursor` to its opposite: word
692/// pairs (`true`/`false`, `yes`/`no`, …) matched as whole words with case
693/// preserved, or symbol pairs (`&&`/`||`, `==`/`!=`, …) at/around the cursor.
694/// Returns the rewritten text and the cursor's new offset, or `None` if nothing
695/// togglable is under the cursor.
696#[must_use]
697pub fn smart_toggle_at(text: &str, cursor: usize) -> Option<(String, usize)> {
698    let chars: Vec<char> = text.chars().collect();
699    let n = chars.len();
700    let is_word = |c: char| c.is_alphanumeric() || c == '_';
701
702    // Word pairs: find the identifier span covering the cursor (or just before it).
703    let mut s = cursor.min(n);
704    while s > 0 && is_word(chars[s - 1]) {
705        s -= 1;
706    }
707    let mut e = s;
708    while e < n && is_word(chars[e]) {
709        e += 1;
710    }
711    if s < e {
712        let word: String = chars[s..e].iter().collect();
713        let lower = word.to_ascii_lowercase();
714        for (a, b) in TOGGLE_WORDS {
715            let to = if lower == *a {
716                Some(*b)
717            } else if lower == *b {
718                Some(*a)
719            } else {
720                None
721            };
722            if let Some(to) = to {
723                let replacement = match_case(&word, to);
724                let mut out: String = chars[..s].iter().collect();
725                out.push_str(&replacement);
726                out.extend(chars[e..].iter());
727                return Some((out, s));
728            }
729        }
730    }
731
732    // Symbol pairs: try each starting at, or one char before, the cursor.
733    for (a, b) in TOGGLE_SYMBOLS {
734        for start in [cursor, cursor.saturating_sub(1)] {
735            for (from, to) in [(*a, *b), (*b, *a)] {
736                let flen = from.chars().count();
737                if start + flen <= n
738                    && chars[start..start + flen].iter().collect::<String>() == from
739                {
740                    let mut out: String = chars[..start].iter().collect();
741                    out.push_str(to);
742                    out.extend(chars[start + flen..].iter());
743                    return Some((out, start));
744                }
745            }
746        }
747    }
748    None
749}
750
751/// Recase `replacement` to match `sample`: all-upper, Titlecase, else lowercase.
752fn match_case(sample: &str, replacement: &str) -> String {
753    if sample
754        .chars()
755        .all(|c| c.is_uppercase() || !c.is_alphabetic())
756        && sample.chars().any(char::is_uppercase)
757    {
758        replacement.to_ascii_uppercase()
759    } else if sample.chars().next().is_some_and(char::is_uppercase) {
760        let mut c = replacement.chars();
761        c.next()
762            .map(|f| f.to_ascii_uppercase().to_string() + c.as_str())
763            .unwrap_or_default()
764    } else {
765        replacement.to_string()
766    }
767}
768
769/// The 0-based char column of `tag` in `line` when it appears as a whole word
770/// (bounded by non-word characters), or `None`. Used by the TODO/FIXME finder.
771#[must_use]
772pub fn tag_column(line: &str, tag: &str) -> Option<usize> {
773    let is_word = |c: char| c.is_alphanumeric() || c == '_';
774    for (col, (byte_idx, _)) in line.char_indices().enumerate() {
775        if line[byte_idx..].starts_with(tag) {
776            let before_ok = line[..byte_idx]
777                .chars()
778                .next_back()
779                .is_none_or(|c| !is_word(c));
780            let after_ok = line[byte_idx + tag.len()..]
781                .chars()
782                .next()
783                .is_none_or(|c| !is_word(c));
784            if before_ok && after_ok {
785                return Some(col);
786            }
787        }
788    }
789    None
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    #[test]
797    fn bump_number_increments_and_decrements() {
798        // Cursor before the digits: increments in place, cursor at the number start.
799        let (out, pos) = bump_number_at("x = 41;", 0, 1).unwrap();
800        assert_eq!(out, "x = 42;");
801        assert_eq!(pos, 4);
802        // Decrement, cursor sitting on a digit.
803        assert_eq!(bump_number_at("v9", 1, -1).unwrap().0, "v8");
804        // Negative numbers: the leading '-' is part of the token.
805        assert_eq!(bump_number_at("-1", 0, -1).unwrap().0, "-2");
806        // No digit on the cursor's line → None.
807        assert!(bump_number_at("no digits here", 0, 1).is_none());
808        // A digit only on a later line is not reached from this line.
809        assert!(bump_number_at("abc\n5", 0, 1).is_none());
810    }
811
812    #[test]
813    fn transpose_chars_swaps_around_the_cursor() {
814        // Cursor between 'a' and 'b' (offset 1): swap → "ba", cursor advances to 2.
815        assert_eq!(transpose_chars_at("ab", 1), Some(("ba".to_string(), 2)));
816        // At end of buffer: swap the last two.
817        assert_eq!(transpose_chars_at("abc", 3), Some(("acb".to_string(), 3)));
818        // No pair at the very start.
819        assert!(transpose_chars_at("ab", 0).is_none());
820        // Never across a newline.
821        assert!(transpose_chars_at("a\nb", 1).is_none());
822    }
823
824    #[test]
825    fn transpose_words_swaps_neighboring_words() {
826        assert_eq!(transpose_words_at("foo bar", 5).unwrap().0, "bar foo");
827        // Punctuation separator is preserved.
828        assert_eq!(
829            transpose_words_at("alpha, beta", 8).unwrap().0,
830            "beta, alpha"
831        );
832        // Only one word → nothing to do.
833        assert!(transpose_words_at("solo", 0).is_none());
834    }
835
836    #[test]
837    fn sentence_starts_splits_on_terminators() {
838        // Two sentences, then one after a newline; abbreviations without a
839        // following space do not split.
840        let text = "One. Two! Three?\nFour";
841        assert_eq!(sentence_starts(text), vec![0, 5, 10, 17]);
842        assert_eq!(sentence_starts("pi is 3.14 today"), vec![0]);
843    }
844
845    #[test]
846    fn transpose_lines_swaps_with_the_line_above() {
847        assert_eq!(transpose_lines_at("a\nb\nc", 2).unwrap().0, "b\na\nc");
848        // Cursor past the last line: the last two swap.
849        assert_eq!(transpose_lines_at("a\nb\n", 4).unwrap().0, "b\na\n");
850        // Nothing above the first line.
851        assert!(transpose_lines_at("a\nb", 0).is_none());
852        assert!(transpose_lines_at("solo", 2).is_none());
853    }
854
855    #[test]
856    fn transpose_sentences_swaps_around_the_cursor() {
857        let (out, pos) = transpose_sentences_at("One. Two. Three.", 5).unwrap();
858        assert_eq!(out, "Two. One. Three.");
859        assert_eq!(pos, 9, "cursor lands after the swapped pair");
860        // The separator (here a newline) stays where it was.
861        assert_eq!(
862            transpose_sentences_at("One.\nTwo.", 5).unwrap().0,
863            "Two.\nOne."
864        );
865        assert!(transpose_sentences_at("Only one.", 0).is_none());
866    }
867
868    #[test]
869    fn transpose_paragraphs_swaps_blank_line_delimited_blocks() {
870        let text = "a1\na2\n\nb1\nb2\n";
871        assert_eq!(
872            transpose_paragraphs_at(text, 6).unwrap().0,
873            "b1\nb2\n\na1\na2\n"
874        );
875        // Inside the first paragraph there is nothing to swap with.
876        assert!(transpose_paragraphs_at(text, 0).is_none());
877    }
878
879    #[test]
880    fn transpose_sections_swaps_across_double_blank_lines() {
881        // A single blank line stays inside a section; two or more break it.
882        let text = "a\n\nb\n\n\nc\n";
883        assert_eq!(transpose_sections_at(text, 9).unwrap().0, "c\n\n\na\n\nb\n");
884        assert!(transpose_sections_at("a\n\nb\n", 0).is_none());
885    }
886
887    #[test]
888    fn delete_char_removes_the_character_at_the_cursor() {
889        let (out, pos) = delete_char_at("abc", 1).unwrap();
890        assert_eq!(out, "ac");
891        assert_eq!(pos, 1, "cursor stays put");
892        // A newline is just another character.
893        assert_eq!(delete_char_at("a\nb", 1).unwrap().0, "ab");
894        // Nothing to delete at the end of the buffer.
895        assert!(delete_char_at("abc", 3).is_none());
896        assert!(delete_char_at("", 0).is_none());
897    }
898
899    #[test]
900    fn delete_word_removes_the_word_and_its_spacing() {
901        let (out, pos) = delete_word_at("one two three", 4).unwrap();
902        assert_eq!(out, "one three");
903        assert_eq!(pos, 4, "cursor lands where the word was");
904        // Between words: the next one goes.
905        assert_eq!(delete_word_at("one two", 3).unwrap().0, "two");
906        // The last word takes the space before it, never the newline after it.
907        assert_eq!(delete_word_at("one two\nthree", 5).unwrap().0, "one\nthree");
908        assert!(delete_word_at("   ", 0).is_none());
909    }
910
911    #[test]
912    fn delete_sentence_removes_the_sentence_at_the_cursor() {
913        assert_eq!(
914            delete_sentence_at("One. Two. Three.", 5).unwrap().0,
915            "One. Three."
916        );
917        // A sentence on its own line leaves the line break alone.
918        assert_eq!(
919            delete_sentence_at("One. Two.\nThree.", 5).unwrap().0,
920            "One.\nThree."
921        );
922        assert_eq!(delete_sentence_at("Only one.", 0).unwrap().0, "");
923        assert!(delete_sentence_at("", 0).is_none());
924    }
925
926    #[test]
927    fn delete_paragraph_removes_the_block_and_its_blank_lines() {
928        let text = "a1\na2\n\nb1\nb2\n";
929        assert_eq!(delete_paragraph_at(text, 0).unwrap().0, "b1\nb2\n");
930        // The last paragraph takes the blank lines before it.
931        assert_eq!(delete_paragraph_at(text, 8).unwrap().0, "a1\na2\n");
932        assert!(delete_paragraph_at("\n\n", 0).is_none());
933    }
934
935    #[test]
936    fn delete_section_removes_the_double_blank_delimited_block() {
937        // A single blank line stays inside a section; two or more break it.
938        let text = "a\n\nb\n\n\nc\n";
939        assert_eq!(delete_section_at(text, 0).unwrap().0, "c\n");
940        assert_eq!(delete_section_at(text, 9).unwrap().0, "a\n\nb\n");
941    }
942
943    #[test]
944    fn wrap_fills_paragraphs_and_keeps_blank_lines() {
945        assert_eq!(wrap("one two three four", 9), "one two\nthree\nfour");
946        // Blank lines separate chunks and are preserved verbatim.
947        assert_eq!(wrap("a b c\n\nd e f\n", 3), "a b\nc\n\nd e\nf\n");
948        // A word longer than the width still gets a line of its own.
949        assert_eq!(wrap("aaaaaa b", 3), "aaaaaa\nb");
950    }
951
952    #[test]
953    fn wrap_keeps_indentation_markers_and_bullets() {
954        // The first line's indentation is repeated on every wrapped line.
955        assert_eq!(wrap("    one two three", 10), "    one\n    two\n    three");
956        // A comment marker shared by every line is kept as the fill prefix.
957        assert_eq!(wrap("// one two\n// three", 9), "// one\n// two\n// three");
958        // Each list item is its own chunk, with a hanging indent.
959        assert_eq!(wrap("- one two\n- three", 7), "- one\n  two\n- three");
960    }
961
962    #[test]
963    fn wrap_paragraph_at_rewrites_only_the_cursor_paragraph() {
964        let text = "one two three\n\nkeep me\n";
965        let (out, pos) = wrap_paragraph_at(text, 0, 7).unwrap();
966        assert_eq!(out, "one two\nthree\n\nkeep me\n");
967        assert_eq!(pos, 13);
968        // Already wrapped → nothing to do.
969        assert!(wrap_paragraph_at(&out, 0, 7).is_none());
970        // No paragraph after the cursor.
971        assert!(wrap_paragraph_at("", 0, 40).is_none());
972    }
973
974    #[test]
975    fn smart_toggle_flips_words_and_symbols() {
976        // Word pair, case preserved.
977        assert_eq!(
978            smart_toggle_at("let ok = true;", 9).unwrap().0,
979            "let ok = false;"
980        );
981        assert_eq!(smart_toggle_at("v = FALSE", 4).unwrap().0, "v = TRUE");
982        assert_eq!(smart_toggle_at("Yes", 0).unwrap().0, "No");
983        // Symbol pair at the cursor.
984        assert_eq!(smart_toggle_at("a && b", 2).unwrap().0, "a || b");
985        assert_eq!(smart_toggle_at("x == y", 2).unwrap().0, "x != y");
986        // Whole-word only: "online" is not "on".
987        assert!(smart_toggle_at("online", 0).is_none());
988        // Nothing togglable.
989        assert!(smart_toggle_at("hello", 0).is_none());
990    }
991
992    #[test]
993    fn tag_column_matches_whole_words_only() {
994        assert_eq!(tag_column("// TODO: fix", "TODO"), Some(3));
995        assert_eq!(
996            tag_column("let todos = 1;", "TODO"),
997            None,
998            "identifier is not a tag"
999        );
1000        assert_eq!(tag_column("no tags here", "TODO"), None);
1001    }
1002
1003    #[test]
1004    fn line_ending_conversions_round_trip() {
1005        assert_eq!(to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
1006        assert_eq!(to_crlf("a\nb\n"), "a\r\nb\r\n");
1007        // Mixed input normalizes cleanly (no doubled \r).
1008        assert_eq!(to_crlf("a\r\nb\n"), "a\r\nb\r\n");
1009    }
1010
1011    #[test]
1012    fn squeeze_collapses_runs_of_blanks() {
1013        assert_eq!(squeeze_blank_lines("a\n\n\n\nb\n"), "a\n\nb\n");
1014        // A single blank line is kept; whitespace-only counts as blank.
1015        assert_eq!(squeeze_blank_lines("a\n\nb"), "a\n\nb");
1016        assert_eq!(squeeze_blank_lines("a\n \n\t\nb"), "a\n \nb");
1017    }
1018
1019    #[test]
1020    fn rot13_is_its_own_inverse() {
1021        assert_eq!(rot13("Hello, World!"), "Uryyb, Jbeyq!");
1022        assert_eq!(rot13(&rot13("Hello, World!")), "Hello, World!");
1023    }
1024
1025    // ---- property-based ("fuzz") tests ------------------------------------
1026
1027    use proptest::prelude::*;
1028
1029    proptest! {
1030        // Arbitrary text (including multibyte) and an ARBITRARY cursor — the
1031        // cursor is a caller-supplied integer, so out-of-range values must be
1032        // clamped, never panic on indexing or a non-char-boundary slice.
1033        #[test]
1034        fn cursor_ops_never_panic(text in ".*", cursor in 0usize..5000, delta in -4i64..4) {
1035            let _ = bump_number_at(&text, cursor, delta);
1036            let _ = transpose_chars_at(&text, cursor);
1037            let _ = transpose_words_at(&text, cursor);
1038            let _ = transpose_lines_at(&text, cursor);
1039            let _ = transpose_sentences_at(&text, cursor);
1040            let _ = transpose_paragraphs_at(&text, cursor);
1041            let _ = transpose_sections_at(&text, cursor);
1042            let _ = delete_char_at(&text, cursor);
1043            let _ = delete_word_at(&text, cursor);
1044            let _ = delete_sentence_at(&text, cursor);
1045            let _ = delete_paragraph_at(&text, cursor);
1046            let _ = delete_section_at(&text, cursor);
1047            let _ = smart_toggle_at(&text, cursor);
1048            let _ = wrap(&text, 0);
1049            let _ = wrap(&text, 40);
1050            let _ = wrap_paragraph_at(&text, cursor, 40);
1051            let _ = to_lf(&text);
1052            let _ = to_crlf(&text);
1053            let _ = squeeze_blank_lines(&text);
1054            let _ = rot13(&text);
1055            let _ = tag_column(&text, "TODO");
1056            let _ = tag_column(&text, &text); // tag == whole line edge case
1057        }
1058
1059        // Any cursor a cursor-op returns must land within the returned text.
1060        #[test]
1061        fn returned_cursor_stays_in_bounds(text in ".*", cursor in 0usize..2000) {
1062            let ops = [
1063                bump_number_at(&text, cursor, 1),
1064                transpose_chars_at(&text, cursor),
1065                transpose_words_at(&text, cursor),
1066                transpose_lines_at(&text, cursor),
1067                transpose_sentences_at(&text, cursor),
1068                transpose_paragraphs_at(&text, cursor),
1069                transpose_sections_at(&text, cursor),
1070                delete_char_at(&text, cursor),
1071                delete_word_at(&text, cursor),
1072                delete_sentence_at(&text, cursor),
1073                delete_paragraph_at(&text, cursor),
1074                delete_section_at(&text, cursor),
1075                smart_toggle_at(&text, cursor),
1076                wrap_paragraph_at(&text, cursor, 40),
1077            ];
1078            for (out, pos) in ops.into_iter().flatten() {
1079                prop_assert!(
1080                    pos <= out.chars().count(),
1081                    "returned cursor {pos} past end {}",
1082                    out.chars().count()
1083                );
1084            }
1085        }
1086    }
1087}