use crate::diagnostics::{BufferTraceId, MutationSource};
use crate::history::{Edit, EditKind, History};
use crate::range::Range;
use crate::{id, layout};
use ropey::Rope;
pub struct Buffer {
pub(crate) trace_identity: BufferTraceId,
pub rope: Rope,
pub path: Option<std::path::PathBuf>,
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>,
}
impl Buffer {
pub fn from_text(text: &str) -> Self {
Self {
trace_identity: BufferTraceId::next(),
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: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let path = path.as_ref();
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 {
trace_identity: BufferTraceId::next(),
rope: Rope::from_str(&text),
path: Some(path.to_path_buf()),
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 Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no file name — :w {path} to name it",
));
};
let path = std::fs::canonicalize(&path).unwrap_or(path);
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",
));
}
write_atomic(std::path::Path::new(&path), &self.rope.to_string(), true)?;
self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
self.dirty = false;
Ok(())
}
pub fn save_as(
&mut self,
path: impl AsRef<std::path::Path>,
force: bool,
) -> std::io::Result<()> {
let target = path.as_ref();
if !force && target.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"file exists — :w! to overwrite",
));
}
write_atomic(target, &self.rope.to_string(), force)?;
self.path = Some(target.to_path_buf());
self.disk_stamp = std::fs::metadata(target).and_then(|m| m.modified()).ok();
self.dirty = false;
Ok(())
}
pub fn cell_col_of(&self, offset: impl Into<id::ByteOffset>) -> u16 {
self.cell_col_with_tab(offset, 8)
}
pub fn cell_col_with_tab(&self, offset: impl Into<id::ByteOffset>, tab: u16) -> u16 {
let offset = offset.into().get();
if self.len_bytes() == 0 {
return 0;
}
let line = self.line_of(offset);
let (s, e) = (self.line_start(line), self.line_end(line));
let text = self.rope.byte_slice(s..e).to_string();
let col = offset.saturating_sub(s);
let layout = layout::LineLayout::build(text.trim_end_matches('\n'), tab.max(1));
layout.cell_at_byte(col.min(layout.len_bytes))
}
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: impl Into<id::LineIndex>) -> usize {
self.rope
.line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
}
pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
let line = line.into().get();
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;
if end > start && self.byte(end - 1) == b'\r' {
end -= 1;
}
}
end
}
pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
self.rope
.byte_to_line(offset.into().get().min(self.len_bytes()))
}
pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
let offset = offset.into();
offset.get() - self.line_start(self.line_of(offset))
}
pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
if self.len_bytes() == 0 {
return 0;
}
self.rope
.byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
}
pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
let off = offset.into().get();
if off < self.len_bytes() {
Some(self.rope.byte(off))
} else {
None
}
}
pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
let off = offset.into().get();
if off == 0 || off == self.len_bytes() {
return true;
}
if off > self.len_bytes() {
return false;
}
match self.rope.try_byte_to_char(off) {
Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
Err(_) => false,
}
}
pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
let mut offset = offset.into().get().min(self.len_bytes());
while offset > 0 && !self.is_boundary(offset) {
offset -= 1;
}
offset
}
pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
let mut offset = offset.into().get().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;
self.trace_history(&ops);
}
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) {
let removed_bytes = self.len_bytes();
self.rope = Rope::from_str(text);
self.epoch += 1;
self.trace_edit(MutationSource::System, 0, removed_bytes, text);
}
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;
self.trace_edit(MutationSource::User, start, end - start, "");
if !self.replaying && !self.readonly {
self.history.record(
Edit {
at: start,
text: text.clone(),
kind: EditKind::Insert,
},
Edit {
at: start,
text: text.clone(),
kind: EditKind::Delete,
},
);
}
text
}
pub fn insert(&mut self, at: impl Into<id::ByteOffset>, text: &str) {
if self.readonly && !self.replaying {
return;
}
let at = self.clamp_boundary(at);
self.rope.insert(self.rope.byte_to_char(at), text);
self.dirty = true;
self.epoch += 1;
self.trace_edit(MutationSource::User, at, 0, text);
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: impl Into<id::LineIndex>) -> String {
let line = line.into().get();
let start = self.line_start(line);
let end = self.line_end(line);
self.rope.byte_slice(start..end).to_string()
}
}
fn write_atomic(target: &std::path::Path, contents: &str, overwrite: bool) -> std::io::Result<()> {
use std::io::Write;
let parent = target
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
match std::fs::metadata(target) {
Ok(metadata) => temporary
.as_file()
.set_permissions(metadata.permissions())?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
temporary.write_all(contents.as_bytes())?;
temporary.as_file().sync_all()?;
let result = if overwrite {
temporary.persist(target)
} else {
temporary.persist_noclobber(target)
};
result.map(|_| ()).map_err(|error| error.error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputEdit {
pub start_byte: usize,
pub old_end_byte: usize,
pub new_end_byte: usize,
pub start_point: (usize, usize),
pub old_end_point: (usize, usize),
pub new_end_point: (usize, usize),
}
impl Buffer {
pub fn point_of(&self, offset: usize) -> (usize, usize) {
let offset = offset.min(self.len_bytes());
(self.line_of(offset), self.col_of(offset))
}
fn point_extent(text: &str) -> (usize, usize) {
let lines = text.bytes().filter(|b| *b == b'\n').count();
let col = if lines == 0 {
text.len()
} else {
text.rsplit('\n').next().map(str::len).unwrap_or(0)
};
(lines, col)
}
pub fn input_edit_of(&self, op: &crate::history::Edit) -> InputEdit {
let start_point = self.point_of(op.at);
let extent = Self::point_extent(&op.text);
match op.kind {
EditKind::Insert => InputEdit {
start_byte: op.at,
old_end_byte: op.at,
new_end_byte: op.at + op.text.len(),
start_point,
old_end_point: start_point,
new_end_point: if extent.0 == 0 {
(start_point.0, start_point.1 + extent.1)
} else {
(start_point.0 + extent.0, extent.1)
},
},
EditKind::Delete => InputEdit {
start_byte: op.at,
old_end_byte: op.at + op.text.len(),
new_end_byte: op.at,
start_point,
old_end_point: if extent.0 == 0 {
(start_point.0, start_point.1 + extent.1)
} else {
(start_point.0 + extent.0, extent.1)
},
new_end_point: start_point,
},
}
}
}
#[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(id::ByteOffset::new(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(id::ByteOffset::new(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(id::ByteOffset::new(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");
}
#[test]
fn non_utf8_filename_opens_and_roundtrips() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
let weird = dir
.path()
.join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
std::fs::write(&weird, "fn main() {}\n").unwrap();
let mut b = Buffer::open(&weird).unwrap();
assert_eq!(b.path.as_deref(), Some(weird.as_path()));
b.insert(0, "// x\n");
b.save(false).unwrap();
assert!(std::fs::read_to_string(&weird).unwrap().starts_with("// x"));
}
}