mod cursor;
mod history;
mod inline_suggestion;
mod insert;
mod jumplist;
mod merge;
mod motion;
mod ops;
mod search;
mod substitute;
mod surround;
mod text_object;
mod vcs_link;
pub type IndentAnimState = (Option<std::time::Instant>, (usize, usize, usize), usize);
pub use inline_suggestion::{RequestId, Suggestion, SuggestionState};
pub use jumplist::{JumpEntry, JumpList};
pub use ops::{flip_case_char_keep_width, to_lower_keep_width, to_upper_keep_width};
pub use search::SearchState;
pub use substitute::{SubsArgs, parse_substitute};
use std::cell::{Cell, RefCell};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::Result;
use crate::buffer_ref::BufferRef;
use crate::mode::Mode;
use crate::syntax::Engine;
use crate::vcs::{self, LineStatus};
#[derive(Default)]
pub struct Buffer {
pub lines: Vec<String>,
pub path: Option<PathBuf>,
pub dirty: bool,
pub yank: String,
pub version: u64,
pub highlighter: Option<Engine>,
pub scroll: Cell<usize>,
pub col_scroll: Cell<usize>,
pub viewport_height: Cell<usize>,
pub pending_center: Cell<bool>,
pub cursor_visual_y: Cell<u16>,
pub indent_anim: Cell<Option<IndentAnimState>>,
pub undo_stack: Vec<Snapshot>,
pub redo_stack: Vec<Snapshot>,
pub vcs_base: Option<Vec<String>>,
pub vcs_diff: RefCell<Option<(u64, Vec<Option<LineStatus>>)>>,
pub disk_meta: Option<FileMeta>,
pub disk_base: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
Unchanged,
Clean,
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileMeta {
pub mtime: SystemTime,
pub len: u64,
}
impl FileMeta {
pub fn of(path: &Path) -> Option<Self> {
let md = fs::metadata(path).ok()?;
let mtime = md.modified().ok()?;
Some(Self {
mtime,
len: md.len(),
})
}
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub lines: Vec<String>,
pub cursor: Cursor,
pub extra_cursors: Vec<Cursor>,
pub dirty: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Cursor {
pub row: usize,
pub col: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct IndentSettings {
pub width: usize,
pub use_tabs: bool,
}
impl Default for IndentSettings {
fn default() -> Self {
Self {
width: 4,
use_tabs: false,
}
}
}
#[derive(Default)]
pub struct Editor {
pub doc: BufferRef,
pub cursor: Cursor,
pub extra_cursors: Vec<Cursor>,
pub mode: Mode,
pub tokens: Vec<crate::action::Token>,
pub jumps: JumpList,
pub cursor_memory: std::collections::HashMap<BufferRef, Cursor>,
}
impl Editor {
pub fn new() -> Self {
Self::for_doc(BufferRef::default())
}
pub fn for_doc(doc: BufferRef) -> Self {
Self {
doc,
..Self::default()
}
}
pub fn adopt_doc(&mut self, next: BufferRef, doc: &Buffer) {
let restored = self.cursor_memory.get(&next).copied().unwrap_or_default();
self.doc = next;
self.extra_cursors.clear();
self.tokens.clear();
self.mode = Mode::default();
let last = doc.lines.len().saturating_sub(1);
self.cursor = Cursor {
row: restored.row.min(last),
col: restored.col,
};
self.clamp_col(doc, false);
}
pub fn reload_from_disk(&mut self, buf: &mut Buffer) -> Result<bool> {
let path = buf
.path
.clone()
.ok_or_else(|| anyhow::anyhow!("no file name"))?;
let text = fs::read_to_string(&path)?;
let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
if lines.is_empty() {
lines.push(String::new());
}
if lines == buf.lines {
buf.disk_meta = FileMeta::of(&path);
buf.disk_base = Some(lines);
return Ok(false);
}
self.snapshot(buf);
buf.lines = lines;
buf.dirty = false;
buf.version = buf.version.wrapping_add(1);
buf.vcs_base = vcs::head_blob_lines(&path);
*buf.vcs_diff.borrow_mut() = None;
buf.disk_meta = FileMeta::of(&path);
buf.disk_base = Some(buf.lines.clone());
self.clamp_cursors_into(&buf.lines);
buf.refresh_highlights();
Ok(true)
}
pub fn merge_from_disk(&mut self, buf: &mut Buffer) -> Result<MergeOutcome> {
let path = buf
.path
.clone()
.ok_or_else(|| anyhow::anyhow!("no file name"))?;
let theirs = fs::read_to_string(&path)?;
let mut disk_lines: Vec<String> = theirs.split('\n').map(|s| s.to_string()).collect();
if disk_lines.is_empty() {
disk_lines.push(String::new());
}
if disk_lines == buf.lines {
buf.disk_meta = FileMeta::of(&path);
buf.disk_base = Some(disk_lines);
buf.dirty = false;
return Ok(MergeOutcome::Unchanged);
}
let Some(base) = buf.disk_base.clone() else {
self.reload_from_disk(buf)?;
return Ok(MergeOutcome::Clean);
};
let (merged_lines, conflict) = merge::three_way(&base, &buf.lines, &disk_lines);
self.snapshot(buf);
buf.lines = merged_lines;
buf.version = buf.version.wrapping_add(1);
buf.vcs_base = vcs::head_blob_lines(&path);
*buf.vcs_diff.borrow_mut() = None;
buf.disk_meta = FileMeta::of(&path);
buf.dirty = buf.lines != disk_lines;
buf.disk_base = Some(disk_lines);
self.clamp_cursors_into(&buf.lines);
buf.refresh_highlights();
Ok(if conflict {
MergeOutcome::Conflict
} else {
MergeOutcome::Clean
})
}
fn clamp_cursors_into(&mut self, lines: &[String]) {
let last_row = lines.len().saturating_sub(1);
let clamp_one = |c: &mut Cursor, lines: &[String]| {
if c.row > last_row {
c.row = last_row;
}
let row_len = lines.get(c.row).map(|s| s.chars().count()).unwrap_or(0);
if c.col > row_len {
c.col = row_len;
}
};
clamp_one(&mut self.cursor, lines);
for c in &mut self.extra_cursors {
clamp_one(c, lines);
}
}
}
impl Buffer {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
..Default::default()
}
}
pub fn load(path: &Path) -> Result<Self> {
let text = fs::read_to_string(path)?;
let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
if lines.is_empty() {
lines.push(String::new());
}
let disk_meta = FileMeta::of(path);
let disk_base = Some(lines.clone());
Ok(Self {
lines,
path: Some(path.to_path_buf()),
dirty: false,
yank: String::new(),
version: 0,
highlighter: None,
scroll: Cell::new(0),
col_scroll: Cell::new(0),
viewport_height: Cell::new(0),
pending_center: Cell::new(false),
cursor_visual_y: Cell::new(0),
indent_anim: Cell::new(None),
undo_stack: Vec::new(),
redo_stack: Vec::new(),
vcs_base: None,
vcs_diff: RefCell::new(None),
disk_meta,
disk_base,
})
}
pub fn save(&mut self) -> Result<()> {
if let Some(p) = &self.path {
fs::write(p, self.lines.join("\n"))?;
self.dirty = false;
self.disk_meta = FileMeta::of(p);
self.disk_base = Some(self.lines.clone());
}
Ok(())
}
pub fn save_as(&mut self, path: &Path) -> Result<()> {
fs::write(path, self.lines.join("\n"))?;
self.path = Some(path.to_path_buf());
self.dirty = false;
self.disk_meta = FileMeta::of(path);
self.disk_base = Some(self.lines.clone());
Ok(())
}
fn touch(&mut self) {
self.dirty = true;
self.version = self.version.wrapping_add(1);
}
pub fn bump_version(&mut self) {
self.version = self.version.wrapping_add(1);
}
pub fn refresh_highlights(&mut self) {
let Some(h) = self.highlighter.as_mut() else {
return;
};
if h.is_current(self.version) {
return;
}
let source = self.lines.join("\n");
h.refresh(&source, self.version);
}
}
fn char_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn byte_to_char(s: &str, byte_idx: usize) -> usize {
s[..byte_idx].chars().count()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CharClass {
Word,
Punct,
Space,
}
fn classify(c: char) -> CharClass {
if c.is_whitespace() {
CharClass::Space
} else if c.is_alphanumeric() || c == '_' {
CharClass::Word
} else {
CharClass::Punct
}
}
fn is_blank_line(line: &str) -> bool {
line.chars().all(|c| c.is_whitespace())
}
#[cfg(test)]
pub(crate) struct Ed {
pub editor: Editor,
pub buffer: Buffer,
}
#[cfg(test)]
#[allow(dead_code)]
impl Ed {
pub fn new() -> Self {
Self {
editor: Editor::new(),
buffer: Buffer::new(),
}
}
pub fn insert_char(&mut self, c: char) {
self.editor.insert_char(&mut self.buffer, c)
}
pub fn insert_char_smart(&mut self, c: char, indent: IndentSettings) {
self.editor.insert_char_smart(&mut self.buffer, c, indent)
}
pub fn insert_newline(&mut self, indent: IndentSettings) {
self.editor.insert_newline(&mut self.buffer, indent)
}
pub fn insert_text_raw(&mut self, s: &str) {
self.editor.insert_text_raw(&mut self.buffer, s)
}
pub fn insert_line_below(&mut self, indent: IndentSettings) {
self.editor.insert_line_below(&mut self.buffer, indent)
}
pub fn insert_line_above(&mut self, indent: IndentSettings) {
self.editor.insert_line_above(&mut self.buffer, indent)
}
pub fn indent_line(&mut self, row: usize, indent: IndentSettings) {
self.editor.indent_line(&mut self.buffer, row, indent)
}
pub fn dedent_line(&mut self, row: usize, indent: IndentSettings) {
self.editor.dedent_line(&mut self.buffer, row, indent)
}
pub fn delete_char_before_smart(&mut self, indent: IndentSettings) {
self.editor
.delete_char_before_smart(&mut self.buffer, indent)
}
pub fn surround_wrap(&mut self, open: &str, close: &str, from: Cursor, to: Cursor) {
self.editor
.surround_wrap(&mut self.buffer, open, close, from, to)
}
pub fn surround_strip(&mut self, lo: Cursor, hi: Cursor) {
self.editor.surround_strip(&mut self.buffer, lo, hi)
}
pub fn surround_replace(&mut self, lo: Cursor, hi: Cursor, new_open: &str, new_close: &str) {
self.editor
.surround_replace(&mut self.buffer, lo, hi, new_open, new_close)
}
pub fn substitute(&mut self, args: &SubsArgs<'_>) -> substitute::SubsOutcome {
self.editor.substitute(&mut self.buffer, args)
}
}
#[cfg(test)]
impl std::ops::Deref for Ed {
type Target = Editor;
fn deref(&self) -> &Editor {
&self.editor
}
}
#[cfg(test)]
impl std::ops::DerefMut for Ed {
fn deref_mut(&mut self) -> &mut Editor {
&mut self.editor
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct TempFile(PathBuf);
impl TempFile {
fn with(contents: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("vorto-merge-{}-{n}.txt", std::process::id()));
fs::write(&path, contents).unwrap();
Self(path)
}
fn write(&self, contents: &str) {
fs::write(&self.0, contents).unwrap();
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
#[test]
fn merge_clean_combines_disjoint_edits() {
let tf = TempFile::with("a\nb\nc");
let mut buf = Buffer::load(&tf.0).unwrap();
let mut ed = Editor::new();
buf.lines[0] = "A".to_string();
buf.dirty = true;
tf.write("a\nb\nC");
let outcome = ed.merge_from_disk(&mut buf).unwrap();
assert_eq!(outcome, MergeOutcome::Clean);
assert_eq!(buf.lines, vec!["A", "b", "C"]);
assert!(buf.dirty);
assert_eq!(
buf.disk_base.as_ref().map(|v| v.join("\n")),
Some("a\nb\nC".to_string())
);
}
#[test]
fn merge_conflict_inserts_markers() {
let tf = TempFile::with("a\nb\nc");
let mut buf = Buffer::load(&tf.0).unwrap();
let mut ed = Editor::new();
buf.lines[1] = "local-b".to_string();
buf.dirty = true;
tf.write("a\ndisk-b\nc");
let outcome = ed.merge_from_disk(&mut buf).unwrap();
assert_eq!(outcome, MergeOutcome::Conflict);
let text = buf.lines.join("\n");
assert!(text.contains("<<<<<<< local (your edits)"), "{text}");
assert!(text.contains("local-b"), "{text}");
assert!(text.contains("disk-b"), "{text}");
assert!(text.contains(">>>>>>> disk"), "{text}");
assert!(buf.dirty);
}
#[test]
fn merge_clean_buffer_just_rebaselines() {
let tf = TempFile::with("a\nb\nc");
let mut buf = Buffer::load(&tf.0).unwrap();
let mut ed = Editor::new();
tf.write("a\nb\nc");
let outcome = ed.merge_from_disk(&mut buf).unwrap();
assert_eq!(outcome, MergeOutcome::Unchanged);
assert!(!buf.dirty);
}
}