use std::borrow::Cow;
use std::io;
use std::path::{Path, PathBuf};
use ropey::Rope;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub const TAB_WIDTH: usize = 4;
pub struct Buffer {
pub rope: Rope,
pub path: Option<PathBuf>,
pub dirty: bool,
backed_up: bool,
}
impl Buffer {
pub fn open(path: Option<PathBuf>) -> io::Result<Self> {
let rope = match &path {
Some(p) if p.exists() => {
let file = std::fs::File::open(p)?;
Rope::from_reader(io::BufReader::new(file))?
}
_ => Rope::new(),
};
Ok(Buffer {
rope,
path,
dirty: false,
backed_up: false,
})
}
pub fn save(&mut self) -> io::Result<()> {
let path = self
.path
.clone()
.ok_or_else(|| io::Error::other("no file name"))?;
if !self.backed_up && path.exists() {
Self::backup(&path);
self.backed_up = true;
}
self.write_atomic(&path)?;
self.dirty = false;
Ok(())
}
pub fn save_as(&mut self, path: PathBuf) -> io::Result<()> {
if self.path.as_deref() == Some(path.as_path()) {
return self.save();
}
let target_existed = path.exists();
if target_existed {
Self::backup(&path);
}
self.write_atomic(&path)?;
self.path = Some(path);
self.backed_up = target_existed;
self.dirty = false;
Ok(())
}
fn backup(path: &Path) {
let mut bak = path.as_os_str().to_owned();
bak.push(".bak");
let _ = std::fs::copy(path, PathBuf::from(bak));
}
fn write_atomic(&self, path: &Path) -> io::Result<()> {
crate::paths::write_atomic_with(path, |file| self.rope.write_to(io::BufWriter::new(file)))
}
pub fn word_count(&self) -> usize {
let mut count = 0usize;
let mut in_word = false;
for c in self.rope.chars() {
let w = c.is_alphanumeric();
if w && !in_word {
count += 1;
}
in_word = w;
}
count
}
pub fn file_name(&self) -> String {
self.path
.as_deref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from("[no file]"))
}
pub fn len_chars(&self) -> usize {
self.rope.len_chars()
}
pub fn len_lines(&self) -> usize {
self.rope.len_lines()
}
pub fn insert(&mut self, at: usize, text: &str) {
self.rope.insert(at, text);
self.dirty = true;
}
pub fn delete(&mut self, range: std::ops::Range<usize>) -> String {
let removed = self.rope.slice(range.clone()).to_string();
self.rope.remove(range);
self.dirty = true;
removed
}
pub fn line_of(&self, char_idx: usize) -> usize {
self.rope.char_to_line(char_idx)
}
pub fn line_start(&self, line: usize) -> usize {
self.rope.line_to_char(line)
}
pub fn line_end(&self, line: usize) -> usize {
let start = self.line_start(line);
let slice = self.rope.line(line);
let mut len = slice.len_chars();
if len > 0 && slice.char(len - 1) == '\n' {
len -= 1;
if len > 0 && slice.char(len - 1) == '\r' {
len -= 1;
}
}
start + len
}
pub fn line_text(&self, line: usize) -> Cow<'_, str> {
let start = self.line_start(line);
let end = self.line_end(line);
Cow::from(self.rope.slice(start..end))
}
pub fn prev_grapheme(&self, char_idx: usize) -> usize {
if char_idx == 0 {
return 0;
}
let line = self.line_of(char_idx);
let start = self.line_start(line);
if char_idx == start {
let prev_end = self.line_end(line - 1);
return prev_end;
}
let text = self.line_text(line);
let col = char_idx - start;
let mut prev = start;
for (off, g) in text.grapheme_indices(true) {
let g_start = text[..off].chars().count();
let g_chars = g.chars().count();
if g_start + g_chars >= col {
prev = start + g_start;
break;
}
prev = start + g_start + g_chars;
}
prev
}
pub fn next_grapheme(&self, char_idx: usize) -> usize {
let len = self.len_chars();
if char_idx >= len {
return len;
}
let line = self.line_of(char_idx);
let end = self.line_end(line);
if char_idx >= end {
return (self.line_start(line + 1)).min(len);
}
let start = self.line_start(line);
let text = self.line_text(line);
let col = char_idx - start;
for (off, g) in text.grapheme_indices(true) {
let g_start = text[..off].chars().count();
if g_start >= col {
return start + g_start + g.chars().count();
}
}
end
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\''
}
pub fn word_right(&self, char_idx: usize) -> usize {
let len = self.len_chars();
let mut i = char_idx;
while i < len && Self::is_word_char(self.rope.char(i)) {
i += 1;
}
while i < len && !Self::is_word_char(self.rope.char(i)) {
i += 1;
}
i
}
pub fn word_left(&self, char_idx: usize) -> usize {
let mut i = char_idx;
while i > 0 && !Self::is_word_char(self.rope.char(i - 1)) {
i -= 1;
}
while i > 0 && Self::is_word_char(self.rope.char(i - 1)) {
i -= 1;
}
i
}
fn is_blank_line(&self, line: usize) -> bool {
self.line_text(line).chars().all(|c| c.is_whitespace())
}
pub fn para_fwd(&self, char_idx: usize) -> usize {
let last = self.len_lines().saturating_sub(1);
let mut line = self.line_of(char_idx);
while line < last && !self.is_blank_line(line) {
line += 1;
}
while line < last && self.is_blank_line(line) {
line += 1;
}
if line == last && self.is_blank_line(line) {
return self.len_chars();
}
self.line_start(line)
}
pub fn para_back(&self, char_idx: usize) -> usize {
let mut line = self.line_of(char_idx);
while line > 0 && self.is_blank_line(line) {
line -= 1;
}
while line > 0 && !self.is_blank_line(line - 1) {
line -= 1;
}
let start = self.line_start(line);
if start < char_idx {
return start;
}
if line == 0 {
return 0;
}
let mut line = line - 1;
while line > 0 && self.is_blank_line(line) {
line -= 1;
}
while line > 0 && !self.is_blank_line(line - 1) {
line -= 1;
}
self.line_start(line)
}
fn is_sentence_end(&self, i: usize) -> bool {
if !matches!(self.rope.char(i), '.' | '!' | '?') {
return false;
}
let len = self.len_chars();
let mut j = i + 1;
while j < len
&& matches!(
self.rope.char(j),
'"' | '\'' | '\u{2019}' | '\u{201d}' | ')' | ']'
)
{
j += 1;
}
j >= len || self.rope.char(j).is_whitespace()
}
fn after_sentence_end(&self, i: usize) -> usize {
let len = self.len_chars();
let mut j = i + 1;
while j < len
&& matches!(
self.rope.char(j),
'"' | '\'' | '\u{2019}' | '\u{201d}' | ')' | ']'
)
{
j += 1;
}
j
}
pub fn sentence_fwd(&self, char_idx: usize) -> usize {
let len = self.len_chars();
let mut i = char_idx;
while i < len {
if self.is_sentence_end(i) {
let mut j = self.after_sentence_end(i);
while j < len && self.rope.char(j).is_whitespace() {
j += 1;
}
if j > char_idx && j < len {
return j;
}
i = j.max(i + 1);
continue;
}
i += 1;
}
len
}
pub fn sentence_back(&self, char_idx: usize) -> usize {
if char_idx == 0 {
return 0;
}
let mut i = char_idx;
while i > 0 && self.rope.char(i - 1).is_whitespace() {
i -= 1;
}
let mut k = i;
while k > 0 {
let c = self.rope.char(k - 1);
if c.is_whitespace() {
let mut w = k - 1;
while w > 0 && self.rope.char(w - 1).is_whitespace() {
w -= 1;
}
if w > 0 && self.is_sentence_end_backwards(w) {
let start = k;
if start < char_idx {
return start;
}
}
k = w;
} else {
k -= 1;
}
}
0
}
fn is_sentence_end_backwards(&self, end: usize) -> bool {
let mut j = end;
while j > 0
&& matches!(
self.rope.char(j - 1),
'"' | '\'' | '\u{2019}' | '\u{201d}' | ')' | ']'
)
{
j -= 1;
}
j > 0 && matches!(self.rope.char(j - 1), '.' | '!' | '?')
}
pub fn find(&self, query: &str, from: usize, whole_word: bool) -> Option<usize> {
if query.is_empty() {
return None;
}
let fold = !query.chars().any(|c| c.is_uppercase());
let q: Vec<char> = if fold {
query
.chars()
.map(|c| c.to_lowercase().next().unwrap_or(c))
.collect()
} else {
query.chars().collect()
};
let len = self.len_chars();
if q.len() > len {
return None;
}
'outer: for start in from..=(len - q.len()) {
for (k, qc) in q.iter().enumerate() {
let mut c = self.rope.char(start + k);
if fold {
c = c.to_lowercase().next().unwrap_or(c);
}
if c != *qc {
continue 'outer;
}
}
if whole_word {
let before_ok = start == 0 || !Self::is_word_char(self.rope.char(start - 1));
let end = start + q.len();
let after_ok = end >= len || !Self::is_word_char(self.rope.char(end));
if !(before_ok && after_ok) {
continue;
}
}
return Some(start);
}
None
}
pub fn visual_col(&self, char_idx: usize) -> usize {
let line = self.line_of(char_idx);
let start = self.line_start(line);
let text = self.line_text(line);
let col = char_idx - start;
let mut vcol = 0usize;
let mut chars_seen = 0usize;
for g in text.graphemes(true) {
if chars_seen >= col {
break;
}
vcol += grapheme_width(g, vcol);
chars_seen += g.chars().count();
}
vcol
}
pub fn char_at_visual_col(&self, line: usize, goal: usize) -> usize {
let start = self.line_start(line);
let text = self.line_text(line);
let mut vcol = 0usize;
let mut chars_seen = 0usize;
for g in text.graphemes(true) {
let w = grapheme_width(g, vcol);
if vcol + w > goal {
return start + chars_seen;
}
vcol += w;
chars_seen += g.chars().count();
}
start + chars_seen
}
}
pub fn grapheme_width(g: &str, at_vcol: usize) -> usize {
if g == "\t" {
TAB_WIDTH - (at_vcol % TAB_WIDTH)
} else {
UnicodeWidthStr::width(g).max(1)
}
}
pub fn wrap_segments(text: &str, width: usize) -> Vec<(usize, usize)> {
let width = width.max(1);
let mut segs: Vec<(usize, usize)> = Vec::new();
let mut seg_start = 0usize; let mut vcol = 0usize;
let mut char_idx = 0usize;
let mut break_at: Option<usize> = None;
for g in text.graphemes(true) {
let g_chars = g.chars().count();
let w = grapheme_width(g, vcol);
if vcol + w > width && char_idx > seg_start {
let cut = match break_at {
Some(b) if b > seg_start => b,
_ => char_idx,
};
segs.push((seg_start, cut));
seg_start = cut;
break_at = None;
vcol = remeasure(text, cut, char_idx);
}
vcol += grapheme_width(g, vcol);
char_idx += g_chars;
if g.chars().all(|c| c == ' ') {
break_at = Some(char_idx);
}
}
segs.push((seg_start, char_idx));
segs
}
pub fn segment_vcol(text: &str, from: usize, to: usize) -> usize {
remeasure(text, from, to)
}
fn remeasure(text: &str, from: usize, to: usize) -> usize {
let mut vcol = 0usize;
let mut idx = 0usize;
for g in text.graphemes(true) {
let n = g.chars().count();
if idx >= to {
break;
}
if idx >= from {
vcol += grapheme_width(g, vcol);
}
idx += n;
}
vcol
}
#[cfg(test)]
mod tests {
use super::*;
fn buf(text: &str) -> Buffer {
Buffer {
rope: Rope::from_str(text),
path: None,
dirty: false,
backed_up: false,
}
}
#[test]
fn line_end_excludes_newline() {
let b = buf("hello\nworld\n");
assert_eq!(b.line_end(0), 5);
assert_eq!(b.line_end(1), 11);
}
#[test]
fn grapheme_movement_over_newline() {
let b = buf("ab\ncd");
assert_eq!(b.next_grapheme(2), 3); assert_eq!(b.prev_grapheme(3), 2); }
#[test]
fn grapheme_movement_combining() {
let b = buf("e\u{301}x");
assert_eq!(b.next_grapheme(0), 2);
assert_eq!(b.prev_grapheme(2), 0);
}
#[test]
fn word_motion() {
let b = buf("one two, three");
assert_eq!(b.word_right(0), 4); assert_eq!(b.word_right(4), 9); assert_eq!(b.word_left(9), 4);
assert_eq!(b.word_left(4), 0);
}
#[test]
fn visual_col_wide_chars() {
let b = buf("日本語");
assert_eq!(b.visual_col(2), 4); assert_eq!(b.char_at_visual_col(0, 4), 2);
assert_eq!(b.char_at_visual_col(0, 3), 1);
}
#[test]
fn paragraph_motion() {
let b = buf("First para line one.\nLine two.\n\nSecond para.\n\n\nThird.\n");
assert_eq!(b.para_fwd(0), 32); assert_eq!(b.para_back(35), 32); assert_eq!(b.para_back(32), 0); }
#[test]
fn sentence_motion() {
let text = "One two. Three four! \"Five.\" Six?\nSeven.";
let b = buf(text);
assert_eq!(b.sentence_fwd(0), 9); assert_eq!(b.sentence_fwd(9), 21); assert_eq!(b.sentence_back(9), 0);
assert_eq!(b.sentence_back(15), 9); }
#[test]
fn find_smartcase() {
let b = buf("The Word and the word again.");
assert_eq!(b.find("word", 0, false), Some(4)); assert_eq!(b.find("Word", 0, false), Some(4)); assert_eq!(b.find("word", 5, false), Some(17));
assert_eq!(b.find("Word", 5, false), None);
}
#[test]
fn find_whole_word() {
let b = buf("sword word wordy");
assert_eq!(b.find("word", 0, true), Some(6));
assert_eq!(b.find("word", 7, true), None);
}
#[test]
fn visual_col_tabs() {
let b = buf("\tx");
assert_eq!(b.visual_col(1), TAB_WIDTH);
}
#[test]
fn wrap_breaks_at_spaces() {
let segs = wrap_segments("hello brave world", 10);
assert_eq!(segs, vec![(0, 6), (6, 12), (12, 17)]);
}
#[test]
fn wrap_hard_breaks_long_words() {
let segs = wrap_segments("abcdefghij", 4);
assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
}
#[test]
fn wrap_empty_line_is_one_segment() {
assert_eq!(wrap_segments("", 10), vec![(0, 0)]);
}
#[test]
fn wrap_exact_fit_no_extra_segment() {
assert_eq!(wrap_segments("abcd", 4), vec![(0, 4)]);
}
fn scratch_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("pstar-buffer-{tag}-{}-{id}", std::process::id()))
}
#[test]
fn failed_save_preserves_buffer_and_previous_file() {
let dir = scratch_dir("save-failure");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("chapter.md");
std::fs::write(&path, "previous good file").unwrap();
let mut buffer = Buffer::open(Some(path.clone())).unwrap();
buffer.insert(buffer.len_chars(), " with unsaved text");
let expected_text = buffer.rope.to_string();
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp~");
std::fs::create_dir(PathBuf::from(tmp)).unwrap();
assert!(buffer.save().is_err());
assert_eq!(buffer.rope.to_string(), expected_text);
assert_eq!(buffer.path.as_deref(), Some(path.as_path()));
assert!(buffer.dirty);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"previous good file"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_as_adopts_alternate_path_only_after_success() {
let dir = scratch_dir("save-as");
std::fs::create_dir_all(&dir).unwrap();
let mut buffer = Buffer::open(None).unwrap();
buffer.insert(0, "work in memory");
let unavailable = dir.join("missing-parent").join("chapter.md");
assert!(buffer.save_as(unavailable).is_err());
assert!(buffer.path.is_none());
assert!(buffer.dirty);
assert_eq!(buffer.rope.to_string(), "work in memory");
let alternate = dir.join("recovered.md");
buffer.save_as(alternate.clone()).unwrap();
assert_eq!(buffer.path.as_deref(), Some(alternate.as_path()));
assert!(!buffer.dirty);
assert_eq!(
std::fs::read_to_string(alternate).unwrap(),
"work in memory"
);
let _ = std::fs::remove_dir_all(dir);
}
}