1use std::fmt;
19
20use std::ops::Range;
21
22use toml_edit::{DocumentMut, Item, Table, TomlError, Value};
23
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum Newline {
27 Lf,
29 CrLf,
31}
32
33#[derive(Debug)]
35pub enum Error {
36 NotUtf8(std::str::Utf8Error),
38 Toml(TomlError),
40 Io(std::io::Error),
42}
43
44impl fmt::Display for Error {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::NotUtf8(e) => write!(f, "not UTF-8: {e}"),
48 Self::Toml(e) => e.fmt(f),
49 Self::Io(e) => e.fmt(f),
50 }
51 }
52}
53
54impl From<std::io::Error> for Error {
55 fn from(e: std::io::Error) -> Self {
56 Self::Io(e)
57 }
58}
59
60impl std::error::Error for Error {}
61
62#[derive(Debug, Clone)]
69pub struct Document {
70 doc: DocumentMut,
71 bom: bool,
72 newline: Newline,
73 final_newline: bool,
74 baseline: String,
78 current: String,
81 undo: Vec<String>,
83 redo: Vec<String>,
85 group: Option<Vec<String>>,
88}
89
90impl Document {
91 pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
97 let text = std::str::from_utf8(bytes).map_err(Error::NotUtf8)?;
98 Self::parse(text)
99 }
100
101 pub fn parse(text: &str) -> Result<Self, Error> {
107 let bom = text.starts_with('\u{feff}');
113 let newline = match text.find('\n') {
117 Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => Newline::CrLf,
118 _ => Newline::Lf,
119 };
120 let final_newline = text.is_empty() || text.ends_with('\n');
121 let doc = text.parse::<DocumentMut>().map_err(Error::Toml)?;
122 let baseline = doc.to_string();
123 Ok(Self {
124 doc,
125 bom,
126 newline,
127 final_newline,
128 current: baseline.clone(),
129 baseline,
130 undo: Vec::new(),
131 redo: Vec::new(),
132 group: None,
133 })
134 }
135
136 #[cfg(feature = "fs")]
142 pub fn from_path(path: &std::path::Path) -> Result<Self, Error> {
143 Self::from_bytes(&std::fs::read(path)?)
144 }
145
146 #[cfg(feature = "fs")]
169 pub fn save_to(&mut self, path: &std::path::Path) -> Result<(), Error> {
170 use std::io::Write as _;
171 let dir = path
172 .parent()
173 .filter(|p| !p.as_os_str().is_empty())
174 .unwrap_or_else(|| std::path::Path::new("."));
175 let original = match std::fs::metadata(path) {
176 Ok(m) => Some(m),
177 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
178 Err(e) => return Err(e.into()),
179 };
180 if original
181 .as_ref()
182 .is_some_and(|m| m.permissions().readonly())
183 {
184 return Err(std::io::Error::new(
185 std::io::ErrorKind::PermissionDenied,
186 "the file is read-only",
187 )
188 .into());
189 }
190
191 let mut builder = tempfile::Builder::new();
192 #[cfg(unix)]
197 {
198 use std::os::unix::fs::PermissionsExt as _;
199 builder.permissions(std::fs::Permissions::from_mode(0o666));
200 }
201 let mut staged = builder.tempfile_in(dir)?;
202 staged.write_all(self.render().as_bytes())?;
203 staged.as_file().sync_all()?;
204 if let Some(original) = original {
205 staged.as_file().set_permissions(original.permissions())?;
206 }
207 staged.persist(path).map_err(|e| e.error)?;
208 self.mark_saved();
209 Ok(())
210 }
211
212 #[must_use]
214 pub fn tree(&self) -> &DocumentMut {
215 &self.doc
216 }
217
218 pub fn tree_mut(&mut self) -> &mut DocumentMut {
220 &mut self.doc
221 }
222
223 #[must_use]
225 pub fn newline(&self) -> Newline {
226 self.newline
227 }
228
229 #[must_use]
231 pub fn has_bom(&self) -> bool {
232 self.bom
233 }
234
235 #[must_use]
238 pub fn render(&self) -> String {
239 let mut text = self.doc.to_string();
240 if !self.final_newline && text.ends_with('\n') {
241 text.pop();
245 }
246 if self.newline == Newline::CrLf {
247 text = with_crlf(&text);
248 }
249 if self.bom {
250 text.insert(0, '\u{feff}');
251 }
252 text
253 }
254
255 #[must_use]
257 pub fn edited(&self) -> bool {
258 self.doc.to_string() != self.baseline
259 }
260
261 pub fn mark_saved(&mut self) {
263 self.baseline = self.doc.to_string();
264 }
265
266 pub fn record(&mut self, group: Option<&[String]>) -> bool {
276 let now = self.doc.to_string();
277 if now == self.current {
278 return false;
279 }
280 let same_row = group.is_some() && self.group.as_deref() == group;
281 if !same_row {
282 let before = std::mem::take(&mut self.current);
283 self.undo.push(before);
284 self.group = group.map(<[String]>::to_vec);
285 }
286 self.current = now;
287 self.redo.clear();
288 true
289 }
290
291 #[must_use]
293 pub fn can_undo(&self) -> bool {
294 !self.undo.is_empty()
295 }
296
297 #[must_use]
299 pub fn can_redo(&self) -> bool {
300 !self.redo.is_empty()
301 }
302
303 pub fn undo(&mut self) -> bool {
308 self.record(None);
309 let Some(before) = self.undo.pop() else {
310 return false;
311 };
312 let now = std::mem::replace(&mut self.current, before);
313 self.redo.push(now);
314 self.restore();
315 true
316 }
317
318 pub fn redo(&mut self) -> bool {
320 let Some(after) = self.redo.pop() else {
321 return false;
322 };
323 let now = std::mem::replace(&mut self.current, after);
324 self.undo.push(now);
325 self.restore();
326 true
327 }
328
329 #[must_use]
338 pub fn lines_of(&self, path: &[String]) -> Option<Range<usize>> {
339 let text = self.doc.to_string();
340 let parsed = toml_edit::Document::parse(text.as_str()).ok()?;
341 let span = span_in_table(parsed.as_table(), path)?;
342 let line_at = |offset: usize| text[..offset.min(text.len())].matches('\n').count();
343 Some(line_at(span.start)..line_at(span.end.saturating_sub(1)) + 1)
344 }
345
346 fn restore(&mut self) {
350 self.doc = self
351 .current
352 .parse::<DocumentMut>()
353 .expect("a rendered document parses");
354 self.group = None;
355 }
356}
357
358fn span_in_table(t: &Table, path: &[String]) -> Option<Range<usize>> {
361 let (head, rest) = path.split_first()?;
362 let (key, item) = t.get_key_value(head)?;
363 if rest.is_empty() {
364 return join(key.span(), item.span());
365 }
366 match item {
367 Item::Table(inner) => span_in_table(inner, rest),
368 Item::ArrayOfTables(a) => {
369 let (index, rest) = rest.split_first()?;
370 let element = a.get(indexed(index)?)?;
371 if rest.is_empty() {
372 element.span()
373 } else {
374 span_in_table(element, rest)
375 }
376 }
377 Item::Value(v) => span_in_value(v, rest),
378 Item::None => None,
379 }
380}
381
382fn span_in_value(v: &Value, path: &[String]) -> Option<Range<usize>> {
385 let (head, rest) = path.split_first()?;
386 match v {
387 Value::InlineTable(t) => {
388 let (key, inner) = t.get_key_value(head)?;
389 if rest.is_empty() {
390 join(key.span(), inner.span())
391 } else {
392 span_in_value(inner.as_value()?, rest)
393 }
394 }
395 Value::Array(a) => {
396 let element = a.get(indexed(head)?)?;
397 if rest.is_empty() {
398 element.span()
399 } else {
400 span_in_value(element, rest)
401 }
402 }
403 _ => None,
404 }
405}
406
407fn indexed(segment: &str) -> Option<usize> {
410 segment.strip_prefix('[')?.strip_suffix(']')?.parse().ok()
411}
412
413fn join(a: Option<Range<usize>>, b: Option<Range<usize>>) -> Option<Range<usize>> {
415 match (a, b) {
416 (Some(a), Some(b)) => Some(a.start.min(b.start)..a.end.max(b.end)),
417 (Some(a), None) | (None, Some(a)) => Some(a),
418 (None, None) => None,
419 }
420}
421
422fn with_crlf(text: &str) -> String {
427 let mut out = String::with_capacity(text.len() + text.len() / 40);
428 let mut previous = '\0';
429 for c in text.chars() {
430 if c == '\n' && previous != '\r' {
431 out.push('\r');
432 }
433 out.push(c);
434 previous = c;
435 }
436 out
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{Document, Error, Newline};
442
443 #[test]
447 fn what_toml_edit_drops_is_put_back() {
448 for text in [
449 "\u{feff}a = 1\n",
450 "a = 1\r\nb = 2\r\n",
451 "a = 1",
452 "\u{feff}a = 1\r\nb = 2",
453 "",
454 ] {
455 let doc = Document::parse(text).expect("valid TOML");
456 assert_eq!(doc.render(), text, "{text:?}");
457 assert!(!doc.edited(), "{text:?} is edited before anything happened");
458 }
459 }
460
461 #[test]
464 fn a_crlf_inside_a_string_is_not_doubled() {
465 let text = "s = \"\"\"\r\nx\r\n\"\"\"\r\n";
466 let doc = Document::parse(text).expect("valid TOML");
467 assert_eq!(doc.newline(), Newline::CrLf);
468 assert_eq!(doc.render(), text);
469 }
470
471 #[test]
475 fn edited_follows_the_document_and_saving_resets_it() {
476 let mut doc = Document::parse("\u{feff}a = 1\r\n").expect("valid TOML");
477 assert!(!doc.edited());
478 doc.tree_mut()["a"] = toml_edit::value(2);
479 assert!(doc.edited());
480 doc.mark_saved();
481 assert!(!doc.edited());
482 assert_eq!(doc.render(), "\u{feff}a = 2\r\n");
483 }
484
485 #[test]
489 fn a_second_byte_order_mark_is_refused() {
490 assert!(Document::parse("\u{feff}a = 1\n").is_ok());
491 assert!(matches!(
492 Document::parse("\u{feff}\u{feff}a = 1\n"),
493 Err(Error::Toml(_))
494 ));
495 }
496
497 #[test]
501 fn changes_in_one_row_are_one_step() {
502 let mut doc = Document::parse("a = \"\"\nb = 0\n").expect("valid TOML");
503 let a = vec!["a".to_owned()];
504 let b = vec!["b".to_owned()];
505 for text in ["x", "xy", "xyz"] {
506 doc.tree_mut()["a"] = toml_edit::value(text);
507 assert!(doc.record(Some(&a)));
508 }
509 doc.tree_mut()["b"] = toml_edit::value(1);
510 assert!(doc.record(Some(&b)));
511 assert!(!doc.record(Some(&b)), "nothing changed");
512 assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
513
514 assert!(doc.undo());
515 assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
516 assert!(doc.undo());
517 assert_eq!(
518 doc.render(),
519 "a = \"\"\nb = 0\n",
520 "the word came back whole"
521 );
522 assert!(!doc.undo(), "nothing left to undo");
523
524 assert!(doc.redo());
525 assert_eq!(doc.render(), "a = \"xyz\"\nb = 0\n");
526 assert!(doc.redo());
527 assert_eq!(doc.render(), "a = \"xyz\"\nb = 1\n");
528 assert!(!doc.redo());
529 }
530
531 #[test]
535 fn a_change_after_an_undo_ends_the_redo_and_an_unrecorded_one_is_kept() {
536 let mut doc = Document::parse("a = 1\n").expect("valid TOML");
537 doc.tree_mut()["a"] = toml_edit::value(2);
538 doc.record(None);
539 assert!(doc.undo());
540 assert!(doc.can_redo());
541 doc.tree_mut()["a"] = toml_edit::value(3);
542 doc.record(None);
543 assert!(!doc.can_redo(), "a new change ended the branch");
544
545 doc.tree_mut()["a"] = toml_edit::value(4);
546 assert!(doc.undo(), "the unrecorded change is a step");
547 assert_eq!(doc.render(), "a = 3\n");
548 assert!(doc.redo());
549 assert_eq!(doc.render(), "a = 4\n");
550 }
551
552 #[test]
556 fn undo_restores_the_text_and_respects_the_baseline() {
557 let text = "\u{feff}# above\r\na = 1 # beside\r\n";
558 let mut doc = Document::parse(text).expect("valid TOML");
559 doc.tree_mut()["a"] = toml_edit::value(2);
560 doc.record(None);
561 doc.mark_saved();
562 doc.tree_mut()["a"] = toml_edit::value(3);
563 doc.record(None);
564 assert!(doc.edited());
565 assert!(doc.undo());
566 assert!(!doc.edited(), "back at what was saved");
567 assert!(doc.undo());
568 assert!(doc.edited(), "before what was saved");
569 assert_eq!(doc.render(), text);
570 }
571
572 #[test]
578 fn every_kind_of_path_lands_on_its_lines() {
579 let text = "\
580first = 1
581[types]
582count = 44
583list = [
584 1,
585 2,
586]
587inline = { a = 1, b = 2 }
588[[runs]]
589id = 1
590[[runs]]
591id = 2
592";
593 let doc = Document::parse(text).expect("valid TOML");
594 let lines = |parts: &[&str]| {
595 let path: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
596 doc.lines_of(&path)
597 };
598 assert_eq!(lines(&["first"]), Some(0..1));
599 assert_eq!(lines(&["types"]), Some(1..2));
600 assert_eq!(lines(&["types", "count"]), Some(2..3));
601 assert_eq!(lines(&["types", "list"]), Some(3..7));
602 assert_eq!(lines(&["types", "list", "[1]"]), Some(5..6));
603 assert_eq!(lines(&["types", "inline", "b"]), Some(7..8));
604 assert_eq!(lines(&["runs", "[1]"]), Some(10..11));
605 assert_eq!(lines(&["runs", "[1]", "id"]), Some(11..12));
606 assert_eq!(lines(&["absent"]), None);
607 assert_eq!(lines(&["types", "list", "[9]"]), None);
608 assert_eq!(lines(&[]), None);
609 }
610
611 #[cfg(feature = "fs")]
615 #[test]
616 fn a_save_writes_the_render_and_a_failed_one_writes_nothing() {
617 let dir = std::env::temp_dir().join(format!("flyleaf-core-{}", std::process::id()));
618 std::fs::create_dir_all(&dir).unwrap();
619 let path = dir.join("doc.toml");
620 std::fs::write(&path, "\u{feff}a = 1\r\n").unwrap();
621
622 let mut doc = Document::from_path(&path).expect("reads");
623 doc.tree_mut()["a"] = toml_edit::value(2);
624 assert!(doc.edited());
625 doc.save_to(&path).expect("saves");
626 assert!(!doc.edited());
627 assert_eq!(
628 std::fs::read(&path).unwrap(),
629 "\u{feff}a = 2\r\n".as_bytes()
630 );
631 assert_eq!(
632 std::fs::read_dir(&dir).unwrap().count(),
633 1,
634 "the staged file is gone"
635 );
636
637 doc.tree_mut()["a"] = toml_edit::value(3);
638 let nowhere = dir.join("missing").join("doc.toml");
639 assert!(matches!(doc.save_to(&nowhere), Err(Error::Io(_))));
640 assert!(doc.edited(), "not marked saved");
641 assert_eq!(
642 std::fs::read(&path).unwrap(),
643 "\u{feff}a = 2\r\n".as_bytes()
644 );
645 std::fs::remove_dir_all(&dir).unwrap();
646 }
647
648 #[cfg(all(feature = "fs", unix))]
653 #[test]
654 fn a_save_keeps_the_mode_and_refuses_a_read_only_file() {
655 use std::os::unix::fs::PermissionsExt as _;
656 let dir = std::env::temp_dir().join(format!("flyleaf-core-mode-{}", std::process::id()));
657 std::fs::create_dir_all(&dir).unwrap();
658 let path = dir.join("doc.toml");
659 std::fs::write(&path, "a = 1\n").unwrap();
660 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
661
662 let mut doc = Document::from_path(&path).expect("reads");
663 doc.tree_mut()["a"] = toml_edit::value(2);
664 doc.save_to(&path).expect("saves");
665 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
666 assert_eq!(mode, 0o640, "the mode the file had");
667
668 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o440)).unwrap();
669 doc.tree_mut()["a"] = toml_edit::value(3);
670 match doc.save_to(&path) {
671 Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
672 other => panic!("{other:?}"),
673 }
674 assert!(doc.edited(), "not marked saved");
675 assert_eq!(std::fs::read_to_string(&path).unwrap(), "a = 2\n");
676 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
677 std::fs::remove_dir_all(&dir).unwrap();
678 }
679
680 #[test]
683 fn the_two_refusals_are_told_apart() {
684 assert!(matches!(
685 Document::from_bytes(b"a = \"\xff\"\n"),
686 Err(Error::NotUtf8(_))
687 ));
688 match Document::from_bytes(b"a = \n") {
689 Err(Error::Toml(e)) => assert!(e.to_string().contains("line 1"), "{e}"),
690 other => panic!("{other:?}"),
691 }
692 }
693}