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) and cursor-relative rewrites
5//! (`(&str, usize) -> Option<(String, usize)>`: increment number, smart toggle,
6//! transpose). The host applies the former via
7//! `App::transform_selection_or_buffer` and the latter via
8//! `App::rewrite_at_cursor`; everything here is unit-tested without a terminal.
9
10#![warn(clippy::pedantic)]
11#![forbid(unsafe_code)]
12#![deny(missing_docs)]
13
14/// Convert all line endings to LF (`\n`), dropping any `\r`.
15#[must_use]
16pub fn to_lf(text: &str) -> String {
17    text.replace("\r\n", "\n").replace('\r', "\n")
18}
19
20/// Convert all line endings to CRLF (`\r\n`). Normalizes to LF first so mixed
21/// input doesn't produce `\r\r\n`.
22#[must_use]
23pub fn to_crlf(text: &str) -> String {
24    to_lf(text).replace('\n', "\r\n")
25}
26
27/// Collapse runs of two or more blank (empty or whitespace-only) lines into a
28/// single empty line. A trailing newline is preserved.
29#[must_use]
30pub fn squeeze_blank_lines(text: &str) -> String {
31    let had_trailing_newline = text.ends_with('\n');
32    let mut out: Vec<&str> = Vec::new();
33    let mut prev_blank = false;
34    for line in text.split('\n') {
35        let blank = line.trim().is_empty();
36        if blank && prev_blank {
37            continue;
38        }
39        out.push(line);
40        prev_blank = blank;
41    }
42    let mut joined = out.join("\n");
43    if had_trailing_newline && !joined.ends_with('\n') {
44        joined.push('\n');
45    }
46    joined
47}
48
49/// ROT13: rotate ASCII letters by 13 (its own inverse); other chars unchanged.
50#[must_use]
51pub fn rot13(text: &str) -> String {
52    text.chars()
53        .map(|c| match c {
54            'a'..='z' => (b'a' + (c as u8 - b'a' + 13) % 26) as char,
55            'A'..='Z' => (b'A' + (c as u8 - b'A' + 13) % 26) as char,
56            _ => c,
57        })
58        .collect()
59}
60
61/// Increment (or decrement, `delta = -1`) the integer at or immediately after the
62/// cursor char offset `cursor` in `text`. Returns the rewritten text and the new
63/// cursor offset (kept at the number's start), or `None` if no digit is found on
64/// the cursor's line at/after the cursor. Handles an optional leading `-`.
65#[must_use]
66pub fn bump_number_at(text: &str, cursor: usize, delta: i64) -> Option<(String, usize)> {
67    let chars: Vec<char> = text.chars().collect();
68    let n = chars.len();
69    // Search from the cursor to the end of the current line for a digit.
70    let mut i = cursor.min(n);
71    while i < n && chars[i] != '\n' && !chars[i].is_ascii_digit() {
72        i += 1;
73    }
74    if i >= n || chars[i] == '\n' {
75        return None;
76    }
77    // Expand left over digits, then include a leading '-' if present.
78    let mut start = i;
79    while start > 0 && chars[start - 1].is_ascii_digit() {
80        start -= 1;
81    }
82    if start > 0 && chars[start - 1] == '-' {
83        start -= 1;
84    }
85    let mut end = i;
86    while end < n && chars[end].is_ascii_digit() {
87        end += 1;
88    }
89    let token: String = chars[start..end].iter().collect();
90    let value: i64 = token.parse().ok()?;
91    let bumped = value.saturating_add(delta).to_string();
92    let mut out: String = chars[..start].iter().collect();
93    out.push_str(&bumped);
94    out.extend(chars[end..].iter());
95    Some((out, start))
96}
97
98/// Transpose the two characters around char offset `cursor` (Emacs `C-t`): swap
99/// the char before the cursor with the one at it, advancing the cursor. At the
100/// end of a line/buffer, swaps the last two characters. Never crosses newlines.
101/// Returns the rewritten text and new cursor, or `None` if there is no pair.
102#[must_use]
103pub fn transpose_chars_at(text: &str, cursor: usize) -> Option<(String, usize)> {
104    let mut chars: Vec<char> = text.chars().collect();
105    let n = chars.len();
106    // The left index of the pair to swap.
107    let i = if cursor >= 1 && cursor < n && chars[cursor] != '\n' {
108        cursor - 1
109    } else if cursor >= 2 {
110        cursor - 2
111    } else {
112        return None;
113    };
114    if i + 1 >= n || chars[i] == '\n' || chars[i + 1] == '\n' {
115        return None;
116    }
117    chars.swap(i, i + 1);
118    Some((chars.iter().collect(), (i + 2).min(n)))
119}
120
121/// Transpose the word before the cursor with the word at/after it (Emacs `M-t`),
122/// preserving the separator between them and leaving the cursor after the moved
123/// pair. Returns `None` if two words can't be found.
124#[must_use]
125pub fn transpose_words_at(text: &str, cursor: usize) -> Option<(String, usize)> {
126    let chars: Vec<char> = text.chars().collect();
127    let n = chars.len();
128    let is_word = |c: char| c.is_alphanumeric() || c == '_';
129    // Start of the second word: the word containing the cursor, else the next one.
130    let mut b = cursor.min(n);
131    if b < n && is_word(chars[b]) {
132        while b > 0 && is_word(chars[b - 1]) {
133            b -= 1;
134        }
135    } else {
136        while b < n && !is_word(chars[b]) {
137            b += 1;
138        }
139    }
140    if b >= n {
141        return None;
142    }
143    let mut b_end = b;
144    while b_end < n && is_word(chars[b_end]) {
145        b_end += 1;
146    }
147    // The first word: the word ending before `b`.
148    let mut a_end = b;
149    while a_end > 0 && !is_word(chars[a_end - 1]) {
150        a_end -= 1;
151    }
152    let mut a = a_end;
153    while a > 0 && is_word(chars[a - 1]) {
154        a -= 1;
155    }
156    if a == a_end {
157        return None; // no preceding word
158    }
159    let word1: String = chars[a..a_end].iter().collect();
160    let sep: String = chars[a_end..b].iter().collect();
161    let word2: String = chars[b..b_end].iter().collect();
162    let mut out: String = chars[..a].iter().collect();
163    out.push_str(&word2);
164    out.push_str(&sep);
165    out.push_str(&word1);
166    out.extend(chars[b_end..].iter());
167    let new_cursor = a + word2.chars().count() + sep.chars().count() + word1.chars().count();
168    Some((out, new_cursor))
169}
170
171/// Opposite-value pairs for [`smart_toggle_at`]. Word pairs are matched
172/// whole-word and case-preserved; symbol pairs are matched literally.
173const TOGGLE_WORDS: &[(&str, &str)] = &[
174    ("true", "false"),
175    ("yes", "no"),
176    ("on", "off"),
177    ("enable", "disable"),
178    ("enabled", "disabled"),
179    ("left", "right"),
180    ("up", "down"),
181    ("min", "max"),
182    ("and", "or"),
183];
184const TOGGLE_SYMBOLS: &[(&str, &str)] = &[
185    ("&&", "||"),
186    ("==", "!="),
187    ("<=", ">="),
188    ("<", ">"),
189    ("++", "--"),
190];
191
192/// Toggle the boolean-ish token at char offset `cursor` to its opposite: word
193/// pairs (`true`/`false`, `yes`/`no`, …) matched as whole words with case
194/// preserved, or symbol pairs (`&&`/`||`, `==`/`!=`, …) at/around the cursor.
195/// Returns the rewritten text and the cursor's new offset, or `None` if nothing
196/// togglable is under the cursor.
197#[must_use]
198pub fn smart_toggle_at(text: &str, cursor: usize) -> Option<(String, usize)> {
199    let chars: Vec<char> = text.chars().collect();
200    let n = chars.len();
201    let is_word = |c: char| c.is_alphanumeric() || c == '_';
202
203    // Word pairs: find the identifier span covering the cursor (or just before it).
204    let mut s = cursor.min(n);
205    while s > 0 && is_word(chars[s - 1]) {
206        s -= 1;
207    }
208    let mut e = s;
209    while e < n && is_word(chars[e]) {
210        e += 1;
211    }
212    if s < e {
213        let word: String = chars[s..e].iter().collect();
214        let lower = word.to_ascii_lowercase();
215        for (a, b) in TOGGLE_WORDS {
216            let to = if lower == *a {
217                Some(*b)
218            } else if lower == *b {
219                Some(*a)
220            } else {
221                None
222            };
223            if let Some(to) = to {
224                let replacement = match_case(&word, to);
225                let mut out: String = chars[..s].iter().collect();
226                out.push_str(&replacement);
227                out.extend(chars[e..].iter());
228                return Some((out, s));
229            }
230        }
231    }
232
233    // Symbol pairs: try each starting at, or one char before, the cursor.
234    for (a, b) in TOGGLE_SYMBOLS {
235        for start in [cursor, cursor.saturating_sub(1)] {
236            for (from, to) in [(*a, *b), (*b, *a)] {
237                let flen = from.chars().count();
238                if start + flen <= n
239                    && chars[start..start + flen].iter().collect::<String>() == from
240                {
241                    let mut out: String = chars[..start].iter().collect();
242                    out.push_str(to);
243                    out.extend(chars[start + flen..].iter());
244                    return Some((out, start));
245                }
246            }
247        }
248    }
249    None
250}
251
252/// Recase `replacement` to match `sample`: all-upper, Titlecase, else lowercase.
253fn match_case(sample: &str, replacement: &str) -> String {
254    if sample
255        .chars()
256        .all(|c| c.is_uppercase() || !c.is_alphabetic())
257        && sample.chars().any(char::is_uppercase)
258    {
259        replacement.to_ascii_uppercase()
260    } else if sample.chars().next().is_some_and(char::is_uppercase) {
261        let mut c = replacement.chars();
262        c.next()
263            .map(|f| f.to_ascii_uppercase().to_string() + c.as_str())
264            .unwrap_or_default()
265    } else {
266        replacement.to_string()
267    }
268}
269
270/// The 0-based char column of `tag` in `line` when it appears as a whole word
271/// (bounded by non-word characters), or `None`. Used by the TODO/FIXME finder.
272#[must_use]
273pub fn tag_column(line: &str, tag: &str) -> Option<usize> {
274    let is_word = |c: char| c.is_alphanumeric() || c == '_';
275    for (col, (byte_idx, _)) in line.char_indices().enumerate() {
276        if line[byte_idx..].starts_with(tag) {
277            let before_ok = line[..byte_idx]
278                .chars()
279                .next_back()
280                .is_none_or(|c| !is_word(c));
281            let after_ok = line[byte_idx + tag.len()..]
282                .chars()
283                .next()
284                .is_none_or(|c| !is_word(c));
285            if before_ok && after_ok {
286                return Some(col);
287            }
288        }
289    }
290    None
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn bump_number_increments_and_decrements() {
299        // Cursor before the digits: increments in place, cursor at the number start.
300        let (out, pos) = bump_number_at("x = 41;", 0, 1).unwrap();
301        assert_eq!(out, "x = 42;");
302        assert_eq!(pos, 4);
303        // Decrement, cursor sitting on a digit.
304        assert_eq!(bump_number_at("v9", 1, -1).unwrap().0, "v8");
305        // Negative numbers: the leading '-' is part of the token.
306        assert_eq!(bump_number_at("-1", 0, -1).unwrap().0, "-2");
307        // No digit on the cursor's line → None.
308        assert!(bump_number_at("no digits here", 0, 1).is_none());
309        // A digit only on a later line is not reached from this line.
310        assert!(bump_number_at("abc\n5", 0, 1).is_none());
311    }
312
313    #[test]
314    fn transpose_chars_swaps_around_the_cursor() {
315        // Cursor between 'a' and 'b' (offset 1): swap → "ba", cursor advances to 2.
316        assert_eq!(transpose_chars_at("ab", 1), Some(("ba".to_string(), 2)));
317        // At end of buffer: swap the last two.
318        assert_eq!(transpose_chars_at("abc", 3), Some(("acb".to_string(), 3)));
319        // No pair at the very start.
320        assert!(transpose_chars_at("ab", 0).is_none());
321        // Never across a newline.
322        assert!(transpose_chars_at("a\nb", 1).is_none());
323    }
324
325    #[test]
326    fn transpose_words_swaps_neighboring_words() {
327        assert_eq!(transpose_words_at("foo bar", 5).unwrap().0, "bar foo");
328        // Punctuation separator is preserved.
329        assert_eq!(
330            transpose_words_at("alpha, beta", 8).unwrap().0,
331            "beta, alpha"
332        );
333        // Only one word → nothing to do.
334        assert!(transpose_words_at("solo", 0).is_none());
335    }
336
337    #[test]
338    fn smart_toggle_flips_words_and_symbols() {
339        // Word pair, case preserved.
340        assert_eq!(
341            smart_toggle_at("let ok = true;", 9).unwrap().0,
342            "let ok = false;"
343        );
344        assert_eq!(smart_toggle_at("v = FALSE", 4).unwrap().0, "v = TRUE");
345        assert_eq!(smart_toggle_at("Yes", 0).unwrap().0, "No");
346        // Symbol pair at the cursor.
347        assert_eq!(smart_toggle_at("a && b", 2).unwrap().0, "a || b");
348        assert_eq!(smart_toggle_at("x == y", 2).unwrap().0, "x != y");
349        // Whole-word only: "online" is not "on".
350        assert!(smart_toggle_at("online", 0).is_none());
351        // Nothing togglable.
352        assert!(smart_toggle_at("hello", 0).is_none());
353    }
354
355    #[test]
356    fn tag_column_matches_whole_words_only() {
357        assert_eq!(tag_column("// TODO: fix", "TODO"), Some(3));
358        assert_eq!(
359            tag_column("let todos = 1;", "TODO"),
360            None,
361            "identifier is not a tag"
362        );
363        assert_eq!(tag_column("no tags here", "TODO"), None);
364    }
365
366    #[test]
367    fn line_ending_conversions_round_trip() {
368        assert_eq!(to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
369        assert_eq!(to_crlf("a\nb\n"), "a\r\nb\r\n");
370        // Mixed input normalizes cleanly (no doubled \r).
371        assert_eq!(to_crlf("a\r\nb\n"), "a\r\nb\r\n");
372    }
373
374    #[test]
375    fn squeeze_collapses_runs_of_blanks() {
376        assert_eq!(squeeze_blank_lines("a\n\n\n\nb\n"), "a\n\nb\n");
377        // A single blank line is kept; whitespace-only counts as blank.
378        assert_eq!(squeeze_blank_lines("a\n\nb"), "a\n\nb");
379        assert_eq!(squeeze_blank_lines("a\n \n\t\nb"), "a\n \nb");
380    }
381
382    #[test]
383    fn rot13_is_its_own_inverse() {
384        assert_eq!(rot13("Hello, World!"), "Uryyb, Jbeyq!");
385        assert_eq!(rot13(&rot13("Hello, World!")), "Hello, World!");
386    }
387
388    // ---- property-based ("fuzz") tests ------------------------------------
389
390    use proptest::prelude::*;
391
392    proptest! {
393        // Arbitrary text (including multibyte) and an ARBITRARY cursor — the
394        // cursor is a caller-supplied integer, so out-of-range values must be
395        // clamped, never panic on indexing or a non-char-boundary slice.
396        #[test]
397        fn cursor_ops_never_panic(text in ".*", cursor in 0usize..5000, delta in -4i64..4) {
398            let _ = bump_number_at(&text, cursor, delta);
399            let _ = transpose_chars_at(&text, cursor);
400            let _ = transpose_words_at(&text, cursor);
401            let _ = smart_toggle_at(&text, cursor);
402            let _ = to_lf(&text);
403            let _ = to_crlf(&text);
404            let _ = squeeze_blank_lines(&text);
405            let _ = rot13(&text);
406            let _ = tag_column(&text, "TODO");
407            let _ = tag_column(&text, &text); // tag == whole line edge case
408        }
409
410        // Any cursor a cursor-op returns must land within the returned text.
411        #[test]
412        fn returned_cursor_stays_in_bounds(text in ".*", cursor in 0usize..2000) {
413            let ops = [
414                bump_number_at(&text, cursor, 1),
415                transpose_chars_at(&text, cursor),
416                transpose_words_at(&text, cursor),
417                smart_toggle_at(&text, cursor),
418            ];
419            for (out, pos) in ops.into_iter().flatten() {
420                prop_assert!(
421                    pos <= out.chars().count(),
422                    "returned cursor {pos} past end {}",
423                    out.chars().count()
424                );
425            }
426        }
427    }
428}