use std::io::Write;
use std::path::Path;
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use ropey::Rope;
use crate::text::buffer::LineEnding;
pub struct SaveOptions {
pub line_ending: LineEnding,
pub final_newline: bool,
pub trim_trailing_whitespace: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FinalNewline {
#[default]
Preserve,
Always,
Never,
}
impl FinalNewline {
pub fn parse(s: &str) -> Option<FinalNewline> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"preserve" | "keep" | "as-is" => FinalNewline::Preserve,
"always" | "true" | "yes" | "on" => FinalNewline::Always,
"never" | "false" | "no" | "off" => FinalNewline::Never,
_ => return None,
})
}
pub fn resolve(self, had_one: bool) -> bool {
match self {
FinalNewline::Preserve => had_one,
FinalNewline::Always => true,
FinalNewline::Never => false,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SavePolicy {
pub trim_trailing_whitespace: bool,
pub final_newline: FinalNewline,
}
impl SavePolicy {
pub fn from_config(cfg: &crate::config::schema::EditorConfig) -> SavePolicy {
SavePolicy {
trim_trailing_whitespace: cfg.trim_on_save,
final_newline: FinalNewline::parse(&cfg.final_newline).unwrap_or_default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AutosaveInterval(u8);
impl AutosaveInterval {
pub const MIN: u8 = 1;
pub const MAX: u8 = 5;
pub const DEFAULT: u8 = 3;
pub fn parse(minutes: u8) -> Option<AutosaveInterval> {
(Self::MIN..=Self::MAX)
.contains(&minutes)
.then_some(AutosaveInterval(minutes))
}
pub fn minutes(self) -> u8 {
self.0
}
pub fn duration(self) -> Duration {
Duration::from_secs(self.0 as u64 * 60)
}
}
impl Default for AutosaveInterval {
fn default() -> Self {
AutosaveInterval(AutosaveInterval::DEFAULT)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Autosave {
pub enabled: bool,
pub every: AutosaveInterval,
}
impl Autosave {
pub fn from_config(cfg: &crate::config::schema::EditorConfig) -> Autosave {
Autosave {
enabled: cfg.autosave,
every: AutosaveInterval::parse(cfg.autosave_interval).unwrap_or_default(),
}
}
pub fn interval(self) -> Option<Duration> {
self.enabled.then(|| self.every.duration())
}
}
pub fn write_atomic(path: &Path, rope: &Rope, opts: &SaveOptions) -> Result<SystemTime> {
let resolved = path
.is_symlink()
.then(|| std::fs::canonicalize(path).ok())
.flatten();
let path = resolved.as_deref().unwrap_or(path);
let mut text: String = rope.chunks().collect();
if opts.trim_trailing_whitespace {
let mut out = String::with_capacity(text.len());
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(line.trim_end());
}
text = out;
}
if opts.final_newline {
if !text.ends_with('\n') {
text.push('\n');
}
} else {
while text.ends_with('\n') {
text.pop();
}
}
if opts.line_ending == LineEnding::Crlf {
text = text.replace('\n', "\r\n");
}
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let dir = dir.unwrap_or_else(|| Path::new("."));
let stem = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
let tmp = dir.join(format!(".{stem}.swtmp"));
let attempt = || -> Result<SystemTime> {
{
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("creating temp file {}", tmp.display()))?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
}
if let Ok(meta) = std::fs::metadata(path) {
let _ = std::fs::set_permissions(&tmp, meta.permissions());
}
std::fs::rename(&tmp, path)
.with_context(|| format!("replacing {} with {}", path.display(), tmp.display()))?;
Ok(std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now()))
};
let result = attempt();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
pub fn changed_externally(path: &Path, known: Option<SystemTime>) -> bool {
let known = match known {
Some(k) => k,
None => return false,
};
match std::fs::metadata(path).and_then(|m| m.modified()) {
Ok(actual) => actual != known,
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("shoin-save-{tag}-{t}-{n}"));
std::fs::create_dir_all(&d).unwrap();
d
}
fn opts(final_newline: bool, trim: bool) -> SaveOptions {
SaveOptions {
line_ending: LineEnding::Lf,
final_newline,
trim_trailing_whitespace: trim,
}
}
#[test]
fn the_final_newline_ladder_parses() {
assert_eq!(FinalNewline::parse("preserve"), Some(FinalNewline::Preserve));
assert_eq!(FinalNewline::parse(" ALWAYS "), Some(FinalNewline::Always));
assert_eq!(FinalNewline::parse("never"), Some(FinalNewline::Never));
assert_eq!(FinalNewline::parse("true"), Some(FinalNewline::Always));
assert_eq!(FinalNewline::parse("alway"), None);
}
#[test]
fn the_policy_resolves_against_what_the_file_had() {
assert!(FinalNewline::Preserve.resolve(true));
assert!(!FinalNewline::Preserve.resolve(false));
assert!(FinalNewline::Always.resolve(false), "always overrules");
assert!(!FinalNewline::Never.resolve(true), "never overrules");
}
#[test]
fn the_autosave_interval_is_one_to_five_minutes() {
for m in 1..=5u8 {
let i = AutosaveInterval::parse(m).expect("in range");
assert_eq!(i.minutes(), m);
assert_eq!(i.duration(), Duration::from_secs(m as u64 * 60));
}
assert_eq!(AutosaveInterval::parse(0), None, "0 is not an interval");
assert_eq!(AutosaveInterval::parse(6), None);
assert_eq!(AutosaveInterval::parse(255), None);
assert_eq!(AutosaveInterval::default().minutes(), 3);
}
#[test]
fn the_editor_section_reaches_the_autosave_timer() {
use crate::config::schema::EditorConfig;
let mut cfg = EditorConfig::default();
assert!(!cfg.autosave, "autosave is off unless asked for");
assert_eq!(
Autosave::from_config(&cfg).interval(),
None,
"off means no deadline at all, not a deadline nobody acts on"
);
cfg.autosave = true;
assert_eq!(
Autosave::from_config(&cfg).interval(),
Some(Duration::from_secs(180)),
"the default interval is three minutes"
);
cfg.autosave_interval = 5;
assert_eq!(
Autosave::from_config(&cfg).interval(),
Some(Duration::from_secs(300))
);
cfg.autosave_interval = 9;
assert_eq!(
Autosave::from_config(&cfg).every.minutes(),
AutosaveInterval::DEFAULT
);
cfg.autosave = false;
cfg.autosave_interval = 2;
assert_eq!(Autosave::from_config(&cfg).interval(), None);
assert_eq!(Autosave::from_config(&cfg).every.minutes(), 2);
}
#[test]
fn the_editor_section_reaches_the_save() {
use crate::config::schema::EditorConfig;
let mut cfg = EditorConfig::default();
assert_eq!(
SavePolicy::from_config(&cfg).final_newline,
FinalNewline::Preserve
);
cfg.final_newline = "never".into();
cfg.trim_on_save = true;
let p = SavePolicy::from_config(&cfg);
assert_eq!(p.final_newline, FinalNewline::Never);
assert!(p.trim_trailing_whitespace);
cfg.final_newline = "alway".into();
assert_eq!(
SavePolicy::from_config(&cfg).final_newline,
FinalNewline::Preserve
);
}
#[test]
fn a_final_newline_is_added_or_stripped_as_asked() {
let d = dir("nl");
let p = d.join("a.md");
write_atomic(&p, &Rope::from_str("one\ntwo"), &opts(true, false)).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "one\ntwo\n");
write_atomic(&p, &Rope::from_str("one\ntwo\n\n\n"), &opts(false, false)).unwrap();
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"one\ntwo",
"every trailing newline goes, not just the last"
);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn trailing_whitespace_goes_only_when_asked() {
let d = dir("trim");
let p = d.join("a.md");
write_atomic(&p, &Rope::from_str("a \nb\t\n"), &opts(true, false)).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "a \nb\t\n");
write_atomic(&p, &Rope::from_str("a \nb\t\n"), &opts(true, true)).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "a\nb\n");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn crlf_is_reapplied_on_the_way_out() {
let d = dir("crlf");
let p = d.join("a.md");
let o = SaveOptions {
line_ending: LineEnding::Crlf,
final_newline: true,
trim_trailing_whitespace: false,
};
write_atomic(&p, &Rope::from_str("one\ntwo\n"), &o).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "one\r\ntwo\r\n");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn no_temp_file_is_left_behind() {
let d = dir("tmp");
let p = d.join("a.md");
write_atomic(&p, &Rope::from_str("body\n"), &opts(true, false)).unwrap();
let left: Vec<String> = std::fs::read_dir(&d)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".swtmp"))
.collect();
assert!(left.is_empty(), "temp files left: {left:?}");
let blocked = d.join("sub");
std::fs::create_dir_all(&blocked).unwrap();
assert!(
write_atomic(&blocked, &Rope::from_str("x\n"), &opts(true, false)).is_err(),
"a directory is not a file to overwrite"
);
let left: Vec<String> = std::fs::read_dir(&d)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".swtmp"))
.collect();
assert!(left.is_empty(), "temp file survived a failed save: {left:?}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn an_external_write_is_noticed() {
let d = dir("mtime");
let p = d.join("a.md");
let t = write_atomic(&p, &Rope::from_str("mine\n"), &opts(true, false)).unwrap();
assert!(!changed_externally(&p, Some(t)));
assert!(!changed_externally(&p, None), "nothing known, nothing to guard");
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(&p, "theirs\n").unwrap();
assert!(changed_externally(&p, Some(t)));
std::fs::remove_dir_all(&d).ok();
}
#[cfg(unix)]
#[test]
fn a_symlink_is_written_through_not_replaced() {
let d = dir("link");
let real = d.join("real.md");
let link = d.join("link.md");
std::fs::write(&real, "old\n").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
write_atomic(&link, &Rope::from_str("new\n"), &opts(true, false)).unwrap();
assert_eq!(std::fs::read_to_string(&real).unwrap(), "new\n");
assert!(link.is_symlink(), "the link is still a link");
std::fs::remove_dir_all(&d).ok();
}
}