use std::ops::Range;
use ratatui::buffer::CellWidth;
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
#[expect(
clippy::disallowed_methods,
reason = "ratatui's rule, a grapheme at a time"
)]
pub fn width(s: &str) -> usize {
if s.is_ascii() {
return s.bytes().filter(|b| !b.is_ascii_control()).count();
}
s.graphemes(true)
.filter(|g| !g.contains(char::is_control))
.map(|g| usize::from(g.cell_width()))
.sum()
}
pub fn spans_width<'a, 'b: 'a>(spans: impl IntoIterator<Item = &'a Span<'b>>) -> usize {
spans.into_iter().map(|s| width(&s.content)).sum()
}
pub fn untab((g, w): (&str, usize), col: usize) -> (&str, usize) {
const TAB: &str = " ";
match g {
"\t" => TAB
.get(col % TAB.len()..)
.map_or((TAB, 4), |s| (s, s.len())),
_ => (g, w),
}
}
pub fn is_hidden(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{061C}'
| '\u{200B}'
| '\u{200E}'
| '\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'
| '\u{2066}'..='\u{2069}'
| '\u{FEFF}'
)
}
pub fn expand_tabs(s: impl Into<String>) -> String {
let s = s.into();
if !s.contains('\t') {
return s;
}
let mut col = 0;
graphemes(&s)
.map(|g| {
let (g, w) = untab(g, col);
col = if g.contains('\n') { 0 } else { col + w };
g
})
.collect()
}
pub fn graphemes(s: &str) -> impl Iterator<Item = (&str, usize)> {
s.graphemes(true).map(|g| (g, width(g)))
}
pub fn truncate(s: &str, max: usize) -> String {
if width(s) <= max {
return s.to_owned();
}
if max == 0 {
return String::new();
}
let mut out = String::new();
let mut used = 0;
for (g, w) in graphemes(s) {
if used + w > max - 1 {
break;
}
out.push_str(g);
used += w;
}
out.push('…');
out
}
pub fn noun(n: u64, word: &str) -> String {
if n == 1 {
return word.to_owned();
}
let consonant_y = word
.strip_suffix('y')
.filter(|stem| !stem.ends_with(['a', 'e', 'i', 'o', 'u']));
match consonant_y {
Some(stem) => format!("{stem}ies"),
None if word.ends_with(['s', 'x']) || word.ends_with("ch") || word.ends_with("sh") => {
format!("{word}es")
}
None => format!("{word}s"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Word {
pub bytes: Range<usize>,
pub cols: Range<usize>,
}
pub fn words(line: &str) -> Vec<Word> {
let mut out: Vec<Word> = Vec::new();
let (mut byte, mut col) = (0, 0);
let mut last = None;
for (g, w) in graphemes(line) {
let w = match untab((g, w), col) {
(drawn, _) if drawn.contains(is_hidden) => 1,
(_, w) => w,
};
let class = (g.chars().next())
.filter(|c| !c.is_whitespace() && !is_hidden(*c))
.map(is_ident);
let (end, to) = (byte + g.len(), col + w);
match (class, out.last_mut()) {
(Some(_), Some(word)) if class == last => (word.bytes.end, word.cols.end) = (end, to),
(Some(_), _) => out.push(Word {
bytes: byte..end,
cols: col..to,
}),
(None, _) => {}
}
(last, byte, col) = (class, end, to);
}
out
}
pub fn word_at(words: &[Word], col: usize) -> Option<&Word> {
words.iter().min_by_key(|w| {
w.cols
.start
.saturating_sub(col)
.max((col + 1).saturating_sub(w.cols.end))
})
}
pub fn step_word(line: &str, col: Option<usize>, forward: bool) -> Option<Word> {
let words = words(line);
let Some(col) = col else {
return words.into_iter().next();
};
let at = word_at(&words, col)?.cols.start;
if forward {
words.into_iter().find(|w| w.cols.start > at)
} else {
words.into_iter().rev().find(|w| w.cols.start < at)
}
}
pub fn reveal(shift: usize, cols: &Range<usize>, room: usize) -> usize {
if cols.start < shift {
cols.start
} else if cols.end > shift + room {
cols.start.min(cols.end - room)
} else {
shift
}
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub fn whole_word(word: &str) -> String {
let start = if word.starts_with(is_ident) {
"\\<"
} else {
""
};
let end = if word.ends_with(is_ident) { "\\>" } else { "" };
format!("{start}{word}{end}")
}
pub fn hits<'a>(hay: &'a str, query: &'a str) -> impl Iterator<Item = Range<usize>> + 'a {
let (start, needle) = match query.strip_prefix("\\<") {
Some(rest) => (true, rest),
None => (false, query),
};
let (end, needle) = match needle.strip_suffix("\\>") {
Some(rest) => (true, rest),
None => (false, needle),
};
let at_edge = move |r: &Range<usize>| {
let before = hay.get(..r.start).and_then(|s| s.chars().next_back());
let after = hay.get(r.end..).and_then(|s| s.chars().next());
!(start && before.is_some_and(is_ident)) && !(end && after.is_some_and(is_ident))
};
(hay.match_indices(needle))
.filter(move |_| !needle.is_empty())
.map(|(at, m)| at..at + m.len())
.filter(at_edge)
}
pub fn has(hay: &str, query: &str) -> bool {
hits(hay, query).next().is_some()
}
pub fn url_at<'a>(line: &'a str, at: &Range<usize>) -> Option<&'a str> {
let mut from = 0;
while let Some(found) = line.get(from..)?.find("http") {
let start = from + found;
let rest = line.get(start..)?;
let len = rest
.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '<' | '>'))
.unwrap_or(rest.len());
let url = rest
.get(..len)?
.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}']);
let scheme = ["https://", "http://"]
.into_iter()
.find(|s| url.starts_with(s));
if scheme.is_some_and(|s| url.len() > s.len())
&& start < at.end
&& at.start < start + url.len()
{
return Some(url);
}
from = start + len.max(1);
}
None
}
pub fn short_sha(oid: &str) -> &str {
oid.get(..7).unwrap_or(oid)
}
pub fn size(bytes: u64) -> String {
match bytes {
0..1024 => format!("{bytes} B"),
1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0),
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn width_counts_only_what_is_drawn() {
assert_eq!(width("a\u{1b}[31mb"), 6);
assert_eq!(width("a\u{1b}b"), 2);
assert_eq!(width("a\u{202e}b"), 2);
assert_eq!(width("漢字"), 4);
assert!(is_hidden('\u{202e}') && is_hidden('\u{200b}') && !is_hidden('\u{200d}'));
}
#[test]
fn words_go_as_vims_do() {
let line = "\tlet x_1 = a.b(); // https://example.com/a?b=c.";
let shown: Vec<&str> = (words(line).iter())
.map(|w| line.get(w.bytes.clone()).unwrap())
.collect();
let expected = [
"let", "x_1", "=", "a", ".", "b", "();", "//", "https", "://",
];
assert_eq!(shown[..10], expected);
assert_eq!(words(line)[0].cols, 4..7, "after the tab");
let example = words(line)
.into_iter()
.find(|w| line.get(w.bytes.clone()).unwrap() == "example");
let url = url_at(line, &example.unwrap().bytes);
assert_eq!(url, Some("https://example.com/a?b=c"));
assert_eq!(url_at(line, &(1..4)), None);
assert_eq!(step_word(line, Some(5), true).unwrap().cols, 8..11);
assert_eq!(step_word(line, Some(5), false), None);
}
#[test]
fn whole_words_match_only_whole() {
let io = whole_word("io");
assert_eq!(io, "\\<io\\>");
let found = |hay| hits(hay, &io).collect::<Vec<_>>();
assert_eq!(found("use std::io;").first(), Some(&(9..11)));
assert!(found("ioctl(fd); ratio").is_empty());
assert!(has("ratio", "io"), "a plain search still finds it inside");
assert_eq!(whole_word("->"), "->", "punctuation has no word edges");
assert!(has("a->b", &whole_word("->")));
}
#[test]
fn counts_say_one_or_many() {
let say = |n, word| format!("{n} {}", noun(n, word));
assert_eq!(say(1, "file"), "1 file");
assert_eq!(say(0, "file"), "0 files");
assert_eq!(say(2, "repository"), "2 repositories");
assert_eq!(say(3, "branch"), "3 branches");
assert_eq!(say(4, "day"), "4 days");
assert_eq!(say(5, "match"), "5 matches");
}
#[test]
fn truncates_with_ellipsis() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 6), "hello…");
assert_eq!(truncate("日本語テキスト", 5), "日本…");
assert_eq!(truncate("abc", 0), "");
assert_eq!(truncate("⚠️ Fix warning in build", 5), "⚠️ F…");
assert_eq!(truncate("👨🍳👨🍳👨🍳", 5), "👨🍳👨🍳…");
assert!(width(&truncate("❤️❤️❤️abcdef", 5)) <= 5);
}
}