use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use ropey::Rope;
use unicode_segmentation::UnicodeSegmentation;
use crate::line_ending::LineEnding;
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,
line_ending: LineEnding,
}
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,
line_ending: LineEnding::detect(s),
}
}
pub fn new_at(path: &Path) -> Self {
Self {
rope: Rope::new(),
path: Some(path.to_path_buf()),
dirty: false,
history: History::default(),
group_depth: 0,
line_ending: LineEnding::default(),
}
}
pub fn from_path(path: &Path) -> Result<Self> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let line_ending = LineEnding::detect(&text);
let text = match line_ending {
LineEnding::Lf => text,
LineEnding::Crlf => text.replace("\r\n", "\n"),
};
Ok(Self {
line_ending,
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 text(&self) -> String {
self.rope.to_string()
}
pub fn text_as_saved(&self) -> String {
match self.line_ending {
LineEnding::Lf => self.text(),
LineEnding::Crlf => self.text().replace('\n', "\r\n"),
}
}
pub fn line_ending(&self) -> LineEnding {
self.line_ending
}
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 text_in_range(&self, start: Position, end: Position) -> String {
let from = self.char_offset(start);
let to = self.char_offset(end);
if from >= to {
return String::new();
}
self.rope.slice(from..to).to_string()
}
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 find_next(&self, query: &SearchQuery, after: Position) -> Option<Selection> {
if query.needle.is_empty() {
return None;
}
let needle: Vec<&str> = query.needle.graphemes(true).collect();
let line_count = self.rope.len_lines();
let first_on_line = |line: usize, min_col: Option<usize>| -> Option<Selection> {
self.with_line_str(line, |text| {
crate::search::find_in_line_with(text, &needle, query)
.into_iter()
.find(|(start, _)| min_col.is_none_or(|min| *start > min))
.map(|(start, end)| Selection {
anchor: Position { line, col: start },
head: Position { line, col: end },
})
})
};
for line in after.line..line_count {
let min_col = (line == after.line).then_some(after.col);
if let Some(hit) = first_on_line(line, min_col) {
return Some(hit);
}
}
for line in 0..=after.line.min(line_count.saturating_sub(1)) {
if let Some(hit) = first_on_line(line, None) {
return Some(hit);
}
}
None
}
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_depth(&self) -> usize {
self.history.depth()
}
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 target = resolve_symlink(&path);
let temp = temp_path_beside(&target);
write_all_and_sync(&temp, &self.rope, self.line_ending)
.with_context(|| format!("writing {}", temp.display()))?;
if let Err(e) = copy_permissions(&target, &temp) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("preserving the mode of {}", target.display()));
}
if let Err(e) = std::fs::rename(&temp, &target) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("replacing {}", target.display()));
}
sync_parent_dir(&target);
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", std::process::id()))
}
fn write_all_and_sync(path: &Path, rope: &Rope, ending: LineEnding) -> std::io::Result<()> {
use std::io::Write;
let mut file = std::fs::File::create(path)?;
for chunk in rope.chunks() {
match ending {
LineEnding::Lf => file.write_all(chunk.as_bytes())?,
LineEnding::Crlf => file.write_all(chunk.replace('\n', "\r\n").as_bytes())?,
}
}
file.flush()?;
file.sync_all()?;
Ok(())
}
fn resolve_symlink(path: &Path) -> PathBuf {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
_ => path.to_path_buf(),
}
}
fn copy_permissions(from: &Path, to: &Path) -> std::io::Result<()> {
let Ok(meta) = std::fs::metadata(from) else {
return Ok(());
};
std::fs::set_permissions(to, meta.permissions())
}
fn sync_parent_dir(path: &Path) {
let Some(parent) = path.parent() else { return };
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}