#![warn(clippy::pedantic)]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#[must_use]
pub fn to_lf(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
#[must_use]
pub fn to_crlf(text: &str) -> String {
to_lf(text).replace('\n', "\r\n")
}
#[must_use]
pub fn squeeze_blank_lines(text: &str) -> String {
let had_trailing_newline = text.ends_with('\n');
let mut out: Vec<&str> = Vec::new();
let mut prev_blank = false;
for line in text.split('\n') {
let blank = line.trim().is_empty();
if blank && prev_blank {
continue;
}
out.push(line);
prev_blank = blank;
}
let mut joined = out.join("\n");
if had_trailing_newline && !joined.ends_with('\n') {
joined.push('\n');
}
joined
}
#[must_use]
pub fn rot13(text: &str) -> String {
text.chars()
.map(|c| match c {
'a'..='z' => (b'a' + (c as u8 - b'a' + 13) % 26) as char,
'A'..='Z' => (b'A' + (c as u8 - b'A' + 13) % 26) as char,
_ => c,
})
.collect()
}
#[must_use]
pub fn bump_number_at(text: &str, cursor: usize, delta: i64) -> Option<(String, usize)> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut i = cursor.min(n);
while i < n && chars[i] != '\n' && !chars[i].is_ascii_digit() {
i += 1;
}
if i >= n || chars[i] == '\n' {
return None;
}
let mut start = i;
while start > 0 && chars[start - 1].is_ascii_digit() {
start -= 1;
}
if start > 0 && chars[start - 1] == '-' {
start -= 1;
}
let mut end = i;
while end < n && chars[end].is_ascii_digit() {
end += 1;
}
let token: String = chars[start..end].iter().collect();
let value: i64 = token.parse().ok()?;
let bumped = value.saturating_add(delta).to_string();
let mut out: String = chars[..start].iter().collect();
out.push_str(&bumped);
out.extend(chars[end..].iter());
Some((out, start))
}
#[must_use]
pub fn transpose_chars_at(text: &str, cursor: usize) -> Option<(String, usize)> {
let mut chars: Vec<char> = text.chars().collect();
let n = chars.len();
let i = if cursor >= 1 && cursor < n && chars[cursor] != '\n' {
cursor - 1
} else if cursor >= 2 {
cursor - 2
} else {
return None;
};
if i + 1 >= n || chars[i] == '\n' || chars[i + 1] == '\n' {
return None;
}
chars.swap(i, i + 1);
Some((chars.iter().collect(), (i + 2).min(n)))
}
#[must_use]
pub fn transpose_words_at(text: &str, cursor: usize) -> Option<(String, usize)> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut b = cursor.min(n);
if b < n && is_word(chars[b]) {
while b > 0 && is_word(chars[b - 1]) {
b -= 1;
}
} else {
while b < n && !is_word(chars[b]) {
b += 1;
}
}
if b >= n {
return None;
}
let mut b_end = b;
while b_end < n && is_word(chars[b_end]) {
b_end += 1;
}
let mut a_end = b;
while a_end > 0 && !is_word(chars[a_end - 1]) {
a_end -= 1;
}
let mut a = a_end;
while a > 0 && is_word(chars[a - 1]) {
a -= 1;
}
if a == a_end {
return None; }
let word1: String = chars[a..a_end].iter().collect();
let sep: String = chars[a_end..b].iter().collect();
let word2: String = chars[b..b_end].iter().collect();
let mut out: String = chars[..a].iter().collect();
out.push_str(&word2);
out.push_str(&sep);
out.push_str(&word1);
out.extend(chars[b_end..].iter());
let new_cursor = a + word2.chars().count() + sep.chars().count() + word1.chars().count();
Some((out, new_cursor))
}
const TOGGLE_WORDS: &[(&str, &str)] = &[
("true", "false"),
("yes", "no"),
("on", "off"),
("enable", "disable"),
("enabled", "disabled"),
("left", "right"),
("up", "down"),
("min", "max"),
("and", "or"),
];
const TOGGLE_SYMBOLS: &[(&str, &str)] = &[
("&&", "||"),
("==", "!="),
("<=", ">="),
("<", ">"),
("++", "--"),
];
#[must_use]
pub fn smart_toggle_at(text: &str, cursor: usize) -> Option<(String, usize)> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut s = cursor.min(n);
while s > 0 && is_word(chars[s - 1]) {
s -= 1;
}
let mut e = s;
while e < n && is_word(chars[e]) {
e += 1;
}
if s < e {
let word: String = chars[s..e].iter().collect();
let lower = word.to_ascii_lowercase();
for (a, b) in TOGGLE_WORDS {
let to = if lower == *a {
Some(*b)
} else if lower == *b {
Some(*a)
} else {
None
};
if let Some(to) = to {
let replacement = match_case(&word, to);
let mut out: String = chars[..s].iter().collect();
out.push_str(&replacement);
out.extend(chars[e..].iter());
return Some((out, s));
}
}
}
for (a, b) in TOGGLE_SYMBOLS {
for start in [cursor, cursor.saturating_sub(1)] {
for (from, to) in [(*a, *b), (*b, *a)] {
let flen = from.chars().count();
if start + flen <= n
&& chars[start..start + flen].iter().collect::<String>() == from
{
let mut out: String = chars[..start].iter().collect();
out.push_str(to);
out.extend(chars[start + flen..].iter());
return Some((out, start));
}
}
}
}
None
}
fn match_case(sample: &str, replacement: &str) -> String {
if sample
.chars()
.all(|c| c.is_uppercase() || !c.is_alphabetic())
&& sample.chars().any(char::is_uppercase)
{
replacement.to_ascii_uppercase()
} else if sample.chars().next().is_some_and(char::is_uppercase) {
let mut c = replacement.chars();
c.next()
.map(|f| f.to_ascii_uppercase().to_string() + c.as_str())
.unwrap_or_default()
} else {
replacement.to_string()
}
}
#[must_use]
pub fn tag_column(line: &str, tag: &str) -> Option<usize> {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
for (col, (byte_idx, _)) in line.char_indices().enumerate() {
if line[byte_idx..].starts_with(tag) {
let before_ok = line[..byte_idx]
.chars()
.next_back()
.is_none_or(|c| !is_word(c));
let after_ok = line[byte_idx + tag.len()..]
.chars()
.next()
.is_none_or(|c| !is_word(c));
if before_ok && after_ok {
return Some(col);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_number_increments_and_decrements() {
let (out, pos) = bump_number_at("x = 41;", 0, 1).unwrap();
assert_eq!(out, "x = 42;");
assert_eq!(pos, 4);
assert_eq!(bump_number_at("v9", 1, -1).unwrap().0, "v8");
assert_eq!(bump_number_at("-1", 0, -1).unwrap().0, "-2");
assert!(bump_number_at("no digits here", 0, 1).is_none());
assert!(bump_number_at("abc\n5", 0, 1).is_none());
}
#[test]
fn transpose_chars_swaps_around_the_cursor() {
assert_eq!(transpose_chars_at("ab", 1), Some(("ba".to_string(), 2)));
assert_eq!(transpose_chars_at("abc", 3), Some(("acb".to_string(), 3)));
assert!(transpose_chars_at("ab", 0).is_none());
assert!(transpose_chars_at("a\nb", 1).is_none());
}
#[test]
fn transpose_words_swaps_neighboring_words() {
assert_eq!(transpose_words_at("foo bar", 5).unwrap().0, "bar foo");
assert_eq!(
transpose_words_at("alpha, beta", 8).unwrap().0,
"beta, alpha"
);
assert!(transpose_words_at("solo", 0).is_none());
}
#[test]
fn smart_toggle_flips_words_and_symbols() {
assert_eq!(
smart_toggle_at("let ok = true;", 9).unwrap().0,
"let ok = false;"
);
assert_eq!(smart_toggle_at("v = FALSE", 4).unwrap().0, "v = TRUE");
assert_eq!(smart_toggle_at("Yes", 0).unwrap().0, "No");
assert_eq!(smart_toggle_at("a && b", 2).unwrap().0, "a || b");
assert_eq!(smart_toggle_at("x == y", 2).unwrap().0, "x != y");
assert!(smart_toggle_at("online", 0).is_none());
assert!(smart_toggle_at("hello", 0).is_none());
}
#[test]
fn tag_column_matches_whole_words_only() {
assert_eq!(tag_column("// TODO: fix", "TODO"), Some(3));
assert_eq!(
tag_column("let todos = 1;", "TODO"),
None,
"identifier is not a tag"
);
assert_eq!(tag_column("no tags here", "TODO"), None);
}
#[test]
fn line_ending_conversions_round_trip() {
assert_eq!(to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
assert_eq!(to_crlf("a\nb\n"), "a\r\nb\r\n");
assert_eq!(to_crlf("a\r\nb\n"), "a\r\nb\r\n");
}
#[test]
fn squeeze_collapses_runs_of_blanks() {
assert_eq!(squeeze_blank_lines("a\n\n\n\nb\n"), "a\n\nb\n");
assert_eq!(squeeze_blank_lines("a\n\nb"), "a\n\nb");
assert_eq!(squeeze_blank_lines("a\n \n\t\nb"), "a\n \nb");
}
#[test]
fn rot13_is_its_own_inverse() {
assert_eq!(rot13("Hello, World!"), "Uryyb, Jbeyq!");
assert_eq!(rot13(&rot13("Hello, World!")), "Hello, World!");
}
use proptest::prelude::*;
proptest! {
#[test]
fn cursor_ops_never_panic(text in ".*", cursor in 0usize..5000, delta in -4i64..4) {
let _ = bump_number_at(&text, cursor, delta);
let _ = transpose_chars_at(&text, cursor);
let _ = transpose_words_at(&text, cursor);
let _ = smart_toggle_at(&text, cursor);
let _ = to_lf(&text);
let _ = to_crlf(&text);
let _ = squeeze_blank_lines(&text);
let _ = rot13(&text);
let _ = tag_column(&text, "TODO");
let _ = tag_column(&text, &text); }
#[test]
fn returned_cursor_stays_in_bounds(text in ".*", cursor in 0usize..2000) {
let ops = [
bump_number_at(&text, cursor, 1),
transpose_chars_at(&text, cursor),
transpose_words_at(&text, cursor),
smart_toggle_at(&text, cursor),
];
for (out, pos) in ops.into_iter().flatten() {
prop_assert!(
pos <= out.chars().count(),
"returned cursor {pos} past end {}",
out.chars().count()
);
}
}
}
}