1#![warn(clippy::pedantic)]
11#![forbid(unsafe_code)]
12#![deny(missing_docs)]
13
14#[must_use]
16pub fn to_lf(text: &str) -> String {
17 text.replace("\r\n", "\n").replace('\r', "\n")
18}
19
20#[must_use]
23pub fn to_crlf(text: &str) -> String {
24 to_lf(text).replace('\n', "\r\n")
25}
26
27#[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#[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#[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 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 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#[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 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#[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 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 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; }
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
171const 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#[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 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 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
252fn 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#[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 let (out, pos) = bump_number_at("x = 41;", 0, 1).unwrap();
301 assert_eq!(out, "x = 42;");
302 assert_eq!(pos, 4);
303 assert_eq!(bump_number_at("v9", 1, -1).unwrap().0, "v8");
305 assert_eq!(bump_number_at("-1", 0, -1).unwrap().0, "-2");
307 assert!(bump_number_at("no digits here", 0, 1).is_none());
309 assert!(bump_number_at("abc\n5", 0, 1).is_none());
311 }
312
313 #[test]
314 fn transpose_chars_swaps_around_the_cursor() {
315 assert_eq!(transpose_chars_at("ab", 1), Some(("ba".to_string(), 2)));
317 assert_eq!(transpose_chars_at("abc", 3), Some(("acb".to_string(), 3)));
319 assert!(transpose_chars_at("ab", 0).is_none());
321 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 assert_eq!(
330 transpose_words_at("alpha, beta", 8).unwrap().0,
331 "beta, alpha"
332 );
333 assert!(transpose_words_at("solo", 0).is_none());
335 }
336
337 #[test]
338 fn smart_toggle_flips_words_and_symbols() {
339 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 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 assert!(smart_toggle_at("online", 0).is_none());
351 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 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 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 use proptest::prelude::*;
391
392 proptest! {
393 #[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); }
409
410 #[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}