1#![warn(clippy::pedantic)]
12#![forbid(unsafe_code)]
13#![deny(missing_docs)]
14
15#[must_use]
17pub fn to_lf(text: &str) -> String {
18 text.replace("\r\n", "\n").replace('\r', "\n")
19}
20
21#[must_use]
24pub fn to_crlf(text: &str) -> String {
25 to_lf(text).replace('\n', "\r\n")
26}
27
28#[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#[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#[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 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 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#[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 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#[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 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 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; }
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#[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
211fn 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#[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
263fn range_is_blank(chars: &[char], r: (usize, usize)) -> bool {
265 chars[r.0..r.1].iter().all(|c| c.is_whitespace())
266}
267
268fn 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#[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#[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#[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
341fn 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#[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#[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#[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#[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
409fn 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 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#[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#[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#[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#[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#[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
510const WRAP_MARKERS: &[&str] = &["///", "//", "#", "--", ";;", ";", ">"];
514
515fn 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
536fn 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
554fn 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 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#[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#[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
670const 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#[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 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 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
751fn 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#[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 let (out, pos) = bump_number_at("x = 41;", 0, 1).unwrap();
800 assert_eq!(out, "x = 42;");
801 assert_eq!(pos, 4);
802 assert_eq!(bump_number_at("v9", 1, -1).unwrap().0, "v8");
804 assert_eq!(bump_number_at("-1", 0, -1).unwrap().0, "-2");
806 assert!(bump_number_at("no digits here", 0, 1).is_none());
808 assert!(bump_number_at("abc\n5", 0, 1).is_none());
810 }
811
812 #[test]
813 fn transpose_chars_swaps_around_the_cursor() {
814 assert_eq!(transpose_chars_at("ab", 1), Some(("ba".to_string(), 2)));
816 assert_eq!(transpose_chars_at("abc", 3), Some(("acb".to_string(), 3)));
818 assert!(transpose_chars_at("ab", 0).is_none());
820 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 assert_eq!(
829 transpose_words_at("alpha, beta", 8).unwrap().0,
830 "beta, alpha"
831 );
832 assert!(transpose_words_at("solo", 0).is_none());
834 }
835
836 #[test]
837 fn sentence_starts_splits_on_terminators() {
838 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 assert_eq!(transpose_lines_at("a\nb\n", 4).unwrap().0, "b\na\n");
850 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 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 assert!(transpose_paragraphs_at(text, 0).is_none());
877 }
878
879 #[test]
880 fn transpose_sections_swaps_across_double_blank_lines() {
881 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 assert_eq!(delete_char_at("a\nb", 1).unwrap().0, "ab");
894 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 assert_eq!(delete_word_at("one two", 3).unwrap().0, "two");
906 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 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 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 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 assert_eq!(wrap("a b c\n\nd e f\n", 3), "a b\nc\n\nd e\nf\n");
948 assert_eq!(wrap("aaaaaa b", 3), "aaaaaa\nb");
950 }
951
952 #[test]
953 fn wrap_keeps_indentation_markers_and_bullets() {
954 assert_eq!(wrap(" one two three", 10), " one\n two\n three");
956 assert_eq!(wrap("// one two\n// three", 9), "// one\n// two\n// three");
958 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 assert!(wrap_paragraph_at(&out, 0, 7).is_none());
970 assert!(wrap_paragraph_at("", 0, 40).is_none());
972 }
973
974 #[test]
975 fn smart_toggle_flips_words_and_symbols() {
976 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 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 assert!(smart_toggle_at("online", 0).is_none());
988 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 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 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 use proptest::prelude::*;
1028
1029 proptest! {
1030 #[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); }
1058
1059 #[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}