mod edit;
use crate::{
fs_utils::{self, FileReadError, FileWriteError, FilesystemConfig},
text_stream::{TextByteStream, TextStreamError},
};
use bevy::prelude::Component;
use std::{
error::Error,
ffi::OsString,
fmt::{Display, Formatter},
io,
path::PathBuf,
};
pub use edit::{BufferEdit, BufferEditReport};
#[derive(Clone, Component, Debug, Default, Eq, PartialEq)]
pub struct BufferFile {
path: Option<PathBuf>,
saved_revision: u64,
}
impl BufferFile {
#[must_use]
pub const fn scratch(saved_revision: u64) -> Self {
Self {
path: None,
saved_revision,
}
}
#[must_use]
pub const fn backed_by(path: PathBuf, saved_revision: u64) -> Self {
Self {
path: Some(path),
saved_revision,
}
}
#[must_use]
pub const fn path(&self) -> Option<&PathBuf> {
self.path.as_ref()
}
#[must_use]
pub const fn is_modified(&self, stream: &TextByteStream) -> bool {
self.saved_revision != stream.revision()
}
pub fn write(
&mut self,
stream: &TextByteStream,
config: &FilesystemConfig,
) -> Result<BufferWriteReport, BufferWriteError> {
let Some(path) = &self.path else {
return Err(BufferWriteError::NoFileName);
};
let written_path = fs_utils::atomic_write(path, stream.as_bytes(), config)
.map_err(BufferWriteError::File)?;
self.saved_revision = stream.revision();
Ok(BufferWriteReport::new(
written_path,
stream.as_str(),
stream.as_bytes().len(),
))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BufferWriteReport {
path: PathBuf,
line_count: usize,
byte_count: usize,
}
impl BufferWriteReport {
fn new(path: PathBuf, text: &str, byte_count: usize) -> Self {
Self {
path,
line_count: line_count(text),
byte_count,
}
}
#[must_use]
pub fn status_text(&self) -> String {
format!(
"\"{}\" {}L, {}B written",
self.path.display(),
self.line_count,
self.byte_count
)
}
}
pub fn open_launch_buffer(
file_argument: Option<OsString>,
fallback_text: &str,
config: &FilesystemConfig,
) -> Result<(TextByteStream, BufferFile), BufferOpenError> {
let Some(file_argument) = file_argument else {
let stream = TextByteStream::new(fallback_text);
return Ok((stream.clone(), BufferFile::scratch(stream.revision())));
};
let read_file = fs_utils::read_text_file(PathBuf::from(file_argument), config)
.map_err(BufferOpenError::File)?;
let stream =
TextByteStream::from_bytes(read_file.bytes).map_err(|source| BufferOpenError::Text {
path: read_file.path.clone(),
source,
})?;
let buffer = BufferFile::backed_by(read_file.path, stream.revision());
Ok((stream, buffer))
}
#[must_use]
pub fn launch_file_argument(arguments: impl IntoIterator<Item = OsString>) -> Option<OsString> {
arguments.into_iter().next()
}
#[derive(Debug)]
pub enum BufferOpenError {
File(FileReadError),
Open {
path: PathBuf,
source: io::Error,
},
Read {
path: PathBuf,
source: io::Error,
},
Text {
path: PathBuf,
source: TextStreamError,
},
}
impl Display for BufferOpenError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(error) => error.fmt(formatter),
Self::Open { path, source } => {
write!(formatter, "failed to open {}: {source}", path.display())
}
Self::Read { path, source } => {
write!(formatter, "failed to read {}: {source}", path.display())
}
Self::Text { path, source } => {
write!(
formatter,
"{} is not valid editor text: {source}",
path.display()
)
}
}
}
}
impl Error for BufferOpenError {}
#[derive(Debug)]
pub enum BufferWriteError {
NoFileName,
File(FileWriteError),
Io(io::Error),
}
fn line_count(text: &str) -> usize {
if text.is_empty() {
0
} else {
text.lines().count()
}
}
#[cfg(test)]
mod tests {
use super::{BufferFile, BufferWriteReport, launch_file_argument};
use crate::{fs_utils::FilesystemConfig, text_stream::TextByteStream};
use std::{
ffi::OsString,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
fn missing_parent_file_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir()
.join(format!("alma-missing-parent-{name}-{nanos}"))
.join("buffer.txt")
}
#[test]
fn launch_argument_uses_first_user_argument() {
assert_eq!(
launch_file_argument([OsString::from("alma"), OsString::from("file.txt")]),
Some(OsString::from("alma"))
);
assert_eq!(
launch_file_argument([OsString::from("file.txt")]),
Some(OsString::from("file.txt"))
);
assert_eq!(launch_file_argument([]), None);
}
#[test]
fn buffer_tracks_saved_revision() {
let mut stream = TextByteStream::new("hello");
let buffer = BufferFile::scratch(stream.revision());
assert!(!buffer.is_modified(&stream));
stream.replace_all("goodbye");
assert!(buffer.is_modified(&stream));
}
#[test]
fn failed_write_does_not_advance_saved_revision() {
let mut stream = TextByteStream::new("hello");
let mut buffer = BufferFile::backed_by(missing_parent_file_path("write-error"), 0);
stream.replace_all("dirty");
assert!(buffer.is_modified(&stream));
assert!(buffer.write(&stream, &FilesystemConfig::default()).is_err());
assert!(buffer.is_modified(&stream));
}
#[test]
fn write_report_matches_vim_shape() {
let report = BufferWriteReport::new(PathBuf::from("note.txt"), "one\ntwo\n", 8);
assert_eq!(report.status_text(), "\"note.txt\" 2L, 8B written");
}
}