pub mod history;
pub mod id;
pub mod selection;
use history::{Edit, EditKind, History};
use ropey::Rope;
pub struct Buffer {
pub rope: Rope,
pub path: Option<String>,
pub dirty: bool,
pub epoch: u64,
pub readonly: bool,
pub name: Option<String>,
pub history: History,
pub replaying: bool,
disk_stamp: Option<std::time::SystemTime>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionShape {
Characterwise { inclusive: bool },
Linewise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Range {
pub start: usize,
pub end: usize,
pub shape: MotionShape,
}
impl Range {
pub fn charwise(start: usize, end: usize) -> Self {
debug_assert!(start <= end);
Self {
start,
end,
shape: MotionShape::Characterwise { inclusive: false },
}
}
pub fn linewise(start: usize, end: usize) -> Self {
debug_assert!(start <= end);
Self {
start,
end,
shape: MotionShape::Linewise,
}
}
pub fn is_linewise(&self) -> bool {
matches!(self.shape, MotionShape::Linewise)
}
pub fn with_inclusive(mut self, inclusive: bool) -> Self {
if let MotionShape::Characterwise { inclusive: i } = &mut self.shape {
*i = inclusive;
}
self
}
pub fn inclusive(&self) -> bool {
matches!(self.shape, MotionShape::Characterwise { inclusive: true })
}
pub fn len(&self) -> usize {
self.end - self.start
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
}
impl Buffer {
pub fn from_text(text: &str) -> Self {
Self {
rope: Rope::from_str(text),
path: None,
dirty: false,
epoch: 0,
readonly: false,
name: None,
history: History::default(),
replaying: false,
disk_stamp: None,
}
}
pub fn open(path: &str) -> std::io::Result<Self> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
Ok(Self {
rope: Rope::from_str(&text),
path: Some(path.to_string()),
dirty: false,
epoch: 0,
readonly: false,
name: None,
history: History::default(),
replaying: false,
disk_stamp,
})
}
pub fn save(&mut self, force: bool) -> std::io::Result<()> {
let Some(path) = self.path.clone() else {
return Ok(());
};
let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
if !force && current.is_some() && current != self.disk_stamp {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"file changed on disk — :w! to force",
));
}
let target = std::path::Path::new(&path);
let tmp = target.with_file_name(format!(
".strop-tmp-{}-{}",
std::process::id(),
target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
));
std::fs::write(&tmp, self.rope.to_string())?;
if let Ok(meta) = std::fs::metadata(target) {
let _ = std::fs::set_permissions(&tmp, meta.permissions());
}
std::fs::rename(&tmp, target)?;
self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
self.dirty = false;
Ok(())
}
pub fn len_bytes(&self) -> usize {
self.rope.len_bytes()
}
pub fn len_lines(&self) -> usize {
self.rope.len_lines()
}
pub fn last_content_line(&self) -> usize {
let mut l = self.len_lines().saturating_sub(1);
if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
l -= 1;
}
l
}
pub fn line_start(&self, line: usize) -> usize {
self.rope
.line_to_byte(line.min(self.len_lines().saturating_sub(1)))
}
pub fn line_end(&self, line: usize) -> usize {
let start = self.line_start(line);
let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
if line + 1 >= self.len_lines() {
end = self.len_bytes();
}
if end > start && self.byte(end - 1) == b'\n' {
end -= 1;
}
end
}
pub fn line_of(&self, offset: usize) -> usize {
self.rope.byte_to_line(offset.min(self.len_bytes()))
}
pub fn col_of(&self, offset: usize) -> usize {
offset - self.line_start(self.line_of(offset))
}
pub fn byte(&self, offset: usize) -> u8 {
self.rope
.byte(offset.min(self.len_bytes().saturating_sub(1)))
}
pub fn byte_at(&self, offset: usize) -> Option<u8> {
if offset < self.len_bytes() {
Some(self.rope.byte(offset))
} else {
None
}
}
pub fn is_boundary(&self, offset: usize) -> bool {
if offset == 0 || offset == self.len_bytes() {
return true;
}
if offset > self.len_bytes() {
return false;
}
match self.rope.try_byte_to_char(offset) {
Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == offset),
Err(_) => false,
}
}
pub fn clamp_boundary(&self, mut offset: usize) -> usize {
offset = offset.min(self.len_bytes());
while offset > 0 && !self.is_boundary(offset) {
offset -= 1;
}
offset
}
pub fn ceil_boundary(&self, mut offset: usize) -> usize {
offset = offset.min(self.len_bytes());
while offset < self.len_bytes() && !self.is_boundary(offset) {
offset += 1;
}
offset
}
pub fn slice_string(&self, range: Range) -> String {
let start = range.start.min(self.len_bytes());
let end = range.end.min(self.len_bytes());
self.rope.byte_slice(start..end.max(start)).to_string()
}
pub fn apply_history(&mut self, ops: Vec<Edit>) {
self.replaying = true;
for op in ops {
match op.kind {
EditKind::Insert => {
let at = self.clamp_boundary(op.at.min(self.len_bytes()));
self.rope.insert(self.rope.byte_to_char(at), &op.text);
}
EditKind::Delete => {
let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
let start = self.clamp_boundary(op.at.min(end));
if start < end {
self.rope
.remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
}
}
}
}
self.replaying = false;
self.dirty = true;
self.epoch += 1;
}
pub fn replace_all(&mut self, text: &str) {
if self.readonly {
return;
}
self.replace_all_system(text);
}
pub fn replace_all_system(&mut self, text: &str) {
self.rope = Rope::from_str(text);
self.epoch += 1;
}
pub fn delete(&mut self, range: Range) -> String {
if self.readonly && !self.replaying {
return String::new();
}
let start = self.clamp_boundary(range.start.min(self.len_bytes()));
let end = self.clamp_boundary(range.end.min(self.len_bytes()));
if start >= end {
return String::new();
}
let text = self.rope.byte_slice(start..end).to_string();
let cstart = self.rope.byte_to_char(start);
let cend = self.rope.byte_to_char(end);
self.rope.remove(cstart..cend);
self.dirty = true;
self.epoch += 1;
if !self.replaying && !self.readonly {
self.history.record(
Edit {
at: range.start,
text: text.clone(),
kind: EditKind::Insert,
},
Edit {
at: range.start,
text: text.clone(),
kind: EditKind::Delete,
},
);
}
text
}
pub fn insert(&mut self, at: usize, text: &str) {
if self.readonly && !self.replaying {
return;
}
self.rope.insert(self.rope.byte_to_char(at), text);
self.dirty = true;
self.epoch += 1;
if !self.replaying && !self.readonly {
self.history.record(
Edit {
at,
text: text.into(),
kind: EditKind::Delete,
},
Edit {
at,
text: text.into(),
kind: EditKind::Insert,
},
);
}
}
pub fn line_text(&self, line: usize) -> String {
let start = self.line_start(line);
let end = self.line_end(line);
self.rope.byte_slice(start..end).to_string()
}
}
#[cfg(test)]
mod safety_tests {
use super::*;
#[test]
fn save_refuses_external_change_unless_forced() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("f.txt");
std::fs::write(&f, "original\n").unwrap();
let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
b.insert(0, "mine ");
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&f, "theirs\n").unwrap();
let err = b.save(false).unwrap_err();
assert!(err.to_string().contains("changed on disk"));
assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
b.save(true).unwrap(); assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
assert!(!b.dirty);
}
#[test]
fn save_is_atomic_and_keeps_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("x.sh");
std::fs::write(&f, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
b.insert(b.len_bytes(), "echo hi\n");
b.save(false).unwrap();
assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o750, "permissions survive the swap");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn readonly_refuses_mutation_at_the_boundary() {
let mut b = Buffer::from_text("abc\n");
b.readonly = true;
b.insert(0, "nope");
let gone = b.delete(Range::charwise(0, 2));
assert_eq!(gone, "");
assert_eq!(b.rope.to_string(), "abc\n", "untouched");
b.replace_all_system("gen\n");
assert_eq!(b.rope.to_string(), "gen\n");
}
}