use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use ropey::Rope;
use unicode_segmentation::UnicodeSegmentation;
use crate::position::Position;
use crate::search::SearchQuery;
use crate::selection::{Selection, Selections};
use crate::undo::{EditKind, History};
pub struct TextBuffer {
rope: Rope,
path: Option<PathBuf>,
dirty: bool,
history: History,
group_depth: usize,
}
impl TextBuffer {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
Self {
rope: Rope::from_str(s),
path: None,
dirty: false,
history: History::default(),
group_depth: 0,
}
}
pub fn from_path(path: &Path) -> Result<Self> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(Self {
rope: Rope::from_str(&text),
path: Some(path.to_path_buf()),
dirty: false,
history: History::default(),
group_depth: 0,
})
}
pub fn line_count(&self) -> usize {
self.rope.len_lines()
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn with_line_str<T>(&self, line: usize, f: impl FnOnce(&str) -> T) -> T {
if line >= self.rope.len_lines() {
return f("");
}
with_slice_str(self.rope.line(line), f)
}
pub fn line_text(&self, line: usize) -> String {
self.with_line_str(line, str::to_string)
}
pub fn line_grapheme_count(&self, line: usize) -> usize {
self.with_line_str(line, |s| s.graphemes(true).count())
}
fn char_offset(&self, pos: Position) -> usize {
let line = pos.line.min(self.rope.len_lines().saturating_sub(1));
let line_start = self.rope.line_to_char(line);
let chars_before: usize = self.with_line_str(line, |text| {
text.graphemes(true)
.take(pos.col)
.map(|g| g.chars().count())
.sum()
});
line_start + chars_before
}
pub fn insert_char(&mut self, pos: Position, ch: char) {
self.record_snapshot(pos);
let offset = self.char_offset(pos);
self.rope.insert_char(offset, ch);
self.dirty = true;
}
pub fn delete_before(&mut self, pos: Position) {
let offset = self.char_offset(pos);
if offset == 0 {
return;
}
let n = if pos.col == 0 {
1 } else {
self.with_line_str(pos.line, |text| {
text.graphemes(true)
.nth(pos.col - 1)
.map_or(1, |g| g.chars().count())
})
};
self.record_snapshot(pos);
self.rope.remove(offset - n..offset);
self.dirty = true;
}
pub fn delete_after(&mut self, pos: Position) {
let offset = self.char_offset(pos);
if offset >= self.rope.len_chars() {
return;
}
let n = self.with_line_str(pos.line, |text| {
text.graphemes(true)
.nth(pos.col)
.map_or(1, |g| g.chars().count())
});
self.record_snapshot(pos);
self.rope.remove(offset..offset + n);
self.dirty = true;
}
pub fn find_all(&self, query: &SearchQuery) -> Vec<Selection> {
let needle: Vec<&str> = query.needle.graphemes(true).collect();
let mut hits = Vec::new();
for (line, slice) in self.rope.lines().enumerate() {
with_slice_str(slice, |text| {
for (start, end) in crate::search::find_in_line_with(text, &needle, query) {
hits.push(Selection {
anchor: Position { line, col: start },
head: Position { line, col: end },
});
}
});
}
hits
}
pub fn replace_range(&mut self, start: Position, end: Position, text: &str) {
let from = self.char_offset(start);
let to = self.char_offset(end);
if from > to || (from == to && text.is_empty()) {
return;
}
self.record_snapshot(start);
if to > from {
self.rope.remove(from..to);
}
if !text.is_empty() {
self.rope.insert(from, text);
}
self.dirty = true;
}
fn record_snapshot(&mut self, at: Position) {
if self.group_depth == 0 {
let selections = Selections::single(Selection::caret(at));
self.history
.record(EditKind::Other, self.rope.clone(), &selections);
}
}
pub fn begin_edit_group(&mut self, kind: EditKind, selections: &Selections) {
if self.group_depth == 0 {
self.history.record(kind, self.rope.clone(), selections);
}
self.group_depth += 1;
}
pub fn end_edit_group(&mut self) {
self.group_depth = self.group_depth.saturating_sub(1);
}
pub fn undo_boundary(&mut self) {
self.history.boundary();
}
pub fn undo(&mut self, current: &Selections) -> Option<Selections> {
let snapshot = self.history.undo(self.rope.clone(), current)?;
self.rope = snapshot.rope;
self.dirty = true;
Some(snapshot.selections)
}
pub fn redo(&mut self, current: &Selections) -> Option<Selections> {
let snapshot = self.history.redo(self.rope.clone(), current)?;
self.rope = snapshot.rope;
self.dirty = true;
Some(snapshot.selections)
}
pub fn save(&mut self) -> Result<()> {
let path = self
.path
.as_ref()
.context("buffer has no path to save to")?
.clone();
let temp = temp_path_beside(&path);
write_all_and_sync(&temp, &self.rope)
.with_context(|| format!("writing {}", temp.display()))?;
if let Err(e) = std::fs::rename(&temp, &path) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("replacing {}", path.display()));
}
self.dirty = false;
Ok(())
}
#[doc(hidden)]
pub fn set_path_for_test(&mut self, path: PathBuf) {
self.path = Some(path);
}
}
fn with_slice_str<T>(slice: ropey::RopeSlice, f: impl FnOnce(&str) -> T) -> T {
match slice.as_str() {
Some(s) => f(trim_line_ending(s)),
None => {
let owned = slice.to_string();
f(trim_line_ending(&owned))
}
}
}
fn trim_line_ending(s: &str) -> &str {
s.strip_suffix('\n')
.map(|s| s.strip_suffix('\r').unwrap_or(s))
.unwrap_or(s)
}
fn temp_path_beside(path: &Path) -> PathBuf {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "buffer".to_string());
let parent = path.parent().unwrap_or(Path::new("."));
parent.join(format!(".{name}.typ-tmp"))
}
fn write_all_and_sync(path: &Path, rope: &Rope) -> std::io::Result<()> {
use std::io::Write;
let mut file = std::fs::File::create(path)?;
for chunk in rope.chunks() {
file.write_all(chunk.as_bytes())?;
}
file.flush()?;
file.sync_all()?;
Ok(())
}