use std::fmt;
use std::ops::Range;
use toml_edit::{DocumentMut, Item, Table, TomlError, Value};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Newline {
Lf,
CrLf,
}
#[derive(Debug)]
pub enum Error {
NotUtf8(std::str::Utf8Error),
Toml(TomlError),
Io(std::io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotUtf8(e) => write!(f, "not UTF-8: {e}"),
Self::Toml(e) => e.fmt(f),
Self::Io(e) => e.fmt(f),
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone)]
pub struct Document {
doc: DocumentMut,
bom: bool,
newline: Newline,
final_newline: bool,
baseline: String,
current: String,
undo: Vec<String>,
redo: Vec<String>,
group: Option<Vec<String>>,
}
impl Document {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
let text = std::str::from_utf8(bytes).map_err(Error::NotUtf8)?;
Self::parse(text)
}
pub fn parse(text: &str) -> Result<Self, Error> {
let bom = text.starts_with('\u{feff}');
let newline = match text.find('\n') {
Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => Newline::CrLf,
_ => Newline::Lf,
};
let final_newline = text.is_empty() || text.ends_with('\n');
let doc = text.parse::<DocumentMut>().map_err(Error::Toml)?;
let baseline = doc.to_string();
Ok(Self {
doc,
bom,
newline,
final_newline,
current: baseline.clone(),
baseline,
undo: Vec::new(),
redo: Vec::new(),
group: None,
})
}
#[cfg(feature = "fs")]
pub fn from_path(path: &std::path::Path) -> Result<Self, Error> {
Self::from_bytes(&std::fs::read(path)?)
}
#[cfg(feature = "fs")]
pub fn save_to(&mut self, path: &std::path::Path) -> Result<(), Error> {
use std::io::Write as _;
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let original = match std::fs::metadata(path) {
Ok(m) => Some(m),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e.into()),
};
if original
.as_ref()
.is_some_and(|m| m.permissions().readonly())
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"the file is read-only",
)
.into());
}
let mut builder = tempfile::Builder::new();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
builder.permissions(std::fs::Permissions::from_mode(0o666));
}
let mut staged = builder.tempfile_in(dir)?;
staged.write_all(self.render().as_bytes())?;
staged.as_file().sync_all()?;
if let Some(original) = original {
staged.as_file().set_permissions(original.permissions())?;
}
staged.persist(path).map_err(|e| e.error)?;
self.mark_saved();
Ok(())
}
#[must_use]
pub fn tree(&self) -> &DocumentMut {
&self.doc
}
pub fn tree_mut(&mut self) -> &mut DocumentMut {
&mut self.doc
}
#[must_use]
pub fn newline(&self) -> Newline {
self.newline
}
#[must_use]
pub fn has_bom(&self) -> bool {
self.bom
}
#[must_use]
pub fn render(&self) -> String {
let mut text = self.doc.to_string();
if !self.final_newline && text.ends_with('\n') {
text.pop();
}
if self.newline == Newline::CrLf {
text = with_crlf(&text);
}
if self.bom {
text.insert(0, '\u{feff}');
}
text
}
#[must_use]
pub fn edited(&self) -> bool {
self.doc.to_string() != self.baseline
}
pub fn mark_saved(&mut self) {
self.baseline = self.doc.to_string();
}
pub fn record(&mut self, group: Option<&[String]>) -> bool {
let now = self.doc.to_string();
if now == self.current {
return false;
}
let same_row = group.is_some() && self.group.as_deref() == group;
if !same_row {
let before = std::mem::take(&mut self.current);
self.undo.push(before);
self.group = group.map(<[String]>::to_vec);
}
self.current = now;
self.redo.clear();
true
}
#[must_use]
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
#[must_use]
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo(&mut self) -> bool {
self.record(None);
let Some(before) = self.undo.pop() else {
return false;
};
let now = std::mem::replace(&mut self.current, before);
self.redo.push(now);
self.restore();
true
}
pub fn redo(&mut self) -> bool {
let Some(after) = self.redo.pop() else {
return false;
};
let now = std::mem::replace(&mut self.current, after);
self.undo.push(now);
self.restore();
true
}
#[must_use]
pub fn lines_of(&self, path: &[String]) -> Option<Range<usize>> {
let text = self.doc.to_string();
let parsed = toml_edit::Document::parse(text.as_str()).ok()?;
let span = span_in_table(parsed.as_table(), path)?;
let line_at = |offset: usize| text[..offset.min(text.len())].matches('\n').count();
Some(line_at(span.start)..line_at(span.end.saturating_sub(1)) + 1)
}
fn restore(&mut self) {
self.doc = self
.current
.parse::<DocumentMut>()
.expect("a rendered document parses");
self.group = None;
}
}
fn span_in_table(t: &Table, path: &[String]) -> Option<Range<usize>> {
let (head, rest) = path.split_first()?;
let (key, item) = t.get_key_value(head)?;
if rest.is_empty() {
return join(key.span(), item.span());
}
match item {
Item::Table(inner) => span_in_table(inner, rest),
Item::ArrayOfTables(a) => {
let (index, rest) = rest.split_first()?;
let element = a.get(indexed(index)?)?;
if rest.is_empty() {
element.span()
} else {
span_in_table(element, rest)
}
}
Item::Value(v) => span_in_value(v, rest),
Item::None => None,
}
}
fn span_in_value(v: &Value, path: &[String]) -> Option<Range<usize>> {
let (head, rest) = path.split_first()?;
match v {
Value::InlineTable(t) => {
let (key, inner) = t.get_key_value(head)?;
if rest.is_empty() {
join(key.span(), inner.span())
} else {
span_in_value(inner.as_value()?, rest)
}
}
Value::Array(a) => {
let element = a.get(indexed(head)?)?;
if rest.is_empty() {
element.span()
} else {
span_in_value(element, rest)
}
}
_ => None,
}
}
fn indexed(segment: &str) -> Option<usize> {
segment.strip_prefix('[')?.strip_suffix(']')?.parse().ok()
}
fn join(a: Option<Range<usize>>, b: Option<Range<usize>>) -> Option<Range<usize>> {
match (a, b) {
(Some(a), Some(b)) => Some(a.start.min(b.start)..a.end.max(b.end)),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
}
}
fn with_crlf(text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 40);
let mut previous = '\0';
for c in text.chars() {
if c == '\n' && previous != '\r' {
out.push('\r');
}
out.push(c);
previous = c;
}
out
}
#[cfg(test)]
mod tests {
use super::{Document, Error, Newline};
#[test]
fn what_toml_edit_drops_is_put_back() {
for text in [
"\u{feff}a = 1\n",
"a = 1\r\nb = 2\r\n",
"a = 1",
"\u{feff}a = 1\r\nb = 2",
"",
] {
let doc = Document::parse(text).expect("valid TOML");
assert_eq!(doc.render(), text, "{text:?}");
assert!(!doc.edited(), "{text:?} is edited before anything happened");
}
}
#[test]
fn a_crlf_inside_a_string_is_not_doubled() {
let text = "s = \"\"\"\r\nx\r\n\"\"\"\r\n";
let doc = Document::parse(text).expect("valid TOML");
assert_eq!(doc.newline(), Newline::CrLf);
assert_eq!(doc.render(), text);
}
#[test]
fn edited_follows_the_document_and_saving_resets_it() {
let mut doc = Document::parse("\u{feff}a = 1\r\n").expect("valid TOML");
assert!(!doc.edited());
doc.tree_mut()["a"] = toml_edit::value(2);
assert!(doc.edited());
doc.mark_saved();
assert!(!doc.edited());
assert_eq!(doc.render(), "\u{feff}a = 2\r\n");
}
#[test]
fn a_second_byte_order_mark_is_refused() {
assert!(Document::parse("\u{feff}a = 1\n").is_ok());
assert!(matches!(
Document::parse("\u{feff}\u{feff}a = 1\n"),
Err(Error::Toml(_))
));
}
#[test]
fn changes_in_one_row_are_one_step() {
let mut doc = Document::parse("a = \"\"\nb = 0\n").expect("valid TOML");
let a = vec!["a".to_owned()];
let b = vec!["b".to_owned()];
for text in ["x", "xy", "xyz"] {
doc.tree_mut()["a"] = toml_edit::value(text);
assert!(doc.record(Some(&a)));
}
doc.tree_mut()["b"] = toml_edit::value(1);
assert!(doc.record(Some(&b)));
assert!(!doc.record(Some(&b)), "nothing changed");
assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
assert!(doc.undo());
assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
assert!(doc.undo());
assert_eq!(
doc.render(),
"a = \"\"\nb = 0\n",
"the word came back whole"
);
assert!(!doc.undo(), "nothing left to undo");
assert!(doc.redo());
assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
assert!(doc.redo());
assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
assert!(!doc.redo());
}
#[test]
fn a_change_after_an_undo_ends_the_redo_and_an_unrecorded_one_is_kept() {
let mut doc = Document::parse("a = 1\n").expect("valid TOML");
doc.tree_mut()["a"] = toml_edit::value(2);
doc.record(None);
assert!(doc.undo());
assert!(doc.can_redo());
doc.tree_mut()["a"] = toml_edit::value(3);
doc.record(None);
assert!(!doc.can_redo(), "a new change ended the branch");
doc.tree_mut()["a"] = toml_edit::value(4);
assert!(doc.undo(), "the unrecorded change is a step");
assert_eq!(doc.render(), "a = 3\n");
assert!(doc.redo());
assert_eq!(doc.render(), "a = 4\n");
}
#[test]
fn undo_restores_the_text_and_respects_the_baseline() {
let text = "\u{feff}# above\r\na = 1 # beside\r\n";
let mut doc = Document::parse(text).expect("valid TOML");
doc.tree_mut()["a"] = toml_edit::value(2);
doc.record(None);
doc.mark_saved();
doc.tree_mut()["a"] = toml_edit::value(3);
doc.record(None);
assert!(doc.edited());
assert!(doc.undo());
assert!(!doc.edited(), "back at what was saved");
assert!(doc.undo());
assert!(doc.edited(), "before what was saved");
assert_eq!(doc.render(), text);
}
#[test]
fn every_kind_of_path_lands_on_its_lines() {
let text = "\
first = 1
[types]
count = 44
list = [
1,
2,
]
inline = { a = 1, b = 2 }
[[runs]]
id = 1
[[runs]]
id = 2
";
let doc = Document::parse(text).expect("valid TOML");
let lines = |parts: &[&str]| {
let path: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
doc.lines_of(&path)
};
assert_eq!(lines(&["first"]), Some(0..1));
assert_eq!(lines(&["types"]), Some(1..2));
assert_eq!(lines(&["types", "count"]), Some(2..3));
assert_eq!(lines(&["types", "list"]), Some(3..7));
assert_eq!(lines(&["types", "list", "[1]"]), Some(5..6));
assert_eq!(lines(&["types", "inline", "b"]), Some(7..8));
assert_eq!(lines(&["runs", "[1]"]), Some(10..11));
assert_eq!(lines(&["runs", "[1]", "id"]), Some(11..12));
assert_eq!(lines(&["absent"]), None);
assert_eq!(lines(&["types", "list", "[9]"]), None);
assert_eq!(lines(&[]), None);
}
#[cfg(feature = "fs")]
#[test]
fn a_save_writes_the_render_and_a_failed_one_writes_nothing() {
let dir = std::env::temp_dir().join(format!("flyleaf-core-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.toml");
std::fs::write(&path, "\u{feff}a = 1\r\n").unwrap();
let mut doc = Document::from_path(&path).expect("reads");
doc.tree_mut()["a"] = toml_edit::value(2);
assert!(doc.edited());
doc.save_to(&path).expect("saves");
assert!(!doc.edited());
assert_eq!(
std::fs::read(&path).unwrap(),
"\u{feff}a = 2\r\n".as_bytes()
);
assert_eq!(
std::fs::read_dir(&dir).unwrap().count(),
1,
"the staged file is gone"
);
doc.tree_mut()["a"] = toml_edit::value(3);
let nowhere = dir.join("missing").join("doc.toml");
assert!(matches!(doc.save_to(&nowhere), Err(Error::Io(_))));
assert!(doc.edited(), "not marked saved");
assert_eq!(
std::fs::read(&path).unwrap(),
"\u{feff}a = 2\r\n".as_bytes()
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(all(feature = "fs", unix))]
#[test]
fn a_save_keeps_the_mode_and_refuses_a_read_only_file() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!("flyleaf-core-mode-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.toml");
std::fs::write(&path, "a = 1\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
let mut doc = Document::from_path(&path).expect("reads");
doc.tree_mut()["a"] = toml_edit::value(2);
doc.save_to(&path).expect("saves");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o640, "the mode the file had");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o440)).unwrap();
doc.tree_mut()["a"] = toml_edit::value(3);
match doc.save_to(&path) {
Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
other => panic!("{other:?}"),
}
assert!(doc.edited(), "not marked saved");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = 2\n");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_two_refusals_are_told_apart() {
assert!(matches!(
Document::from_bytes(b"a = \"\xff\"\n"),
Err(Error::NotUtf8(_))
));
match Document::from_bytes(b"a = \n") {
Err(Error::Toml(e)) => assert!(e.to_string().contains("line 1"), "{e}"),
other => panic!("{other:?}"),
}
}
}