#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EolState {
pub endofline: bool,
pub loaded_empty: bool,
}
impl Default for EolState {
fn default() -> Self {
Self {
endofline: true,
loaded_empty: true,
}
}
}
impl EolState {
pub fn from_loaded(content: &str) -> Self {
Self {
endofline: content.is_empty() || content.ends_with('\n'),
loaded_empty: content.is_empty(),
}
}
pub fn trailing_newline(self, body: &str, fixendofline: bool) -> bool {
if body.is_empty() && self.loaded_empty {
return false;
}
self.endofline || fixendofline
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndOfLineToken {
Set(bool),
Toggle,
Query,
}
pub fn parse_endofline_token(token: &str) -> Option<EndOfLineToken> {
match token {
"endofline" | "eol" => Some(EndOfLineToken::Set(true)),
"noendofline" | "noeol" => Some(EndOfLineToken::Set(false)),
"endofline!" | "eol!" => Some(EndOfLineToken::Toggle),
"endofline?" | "eol?" => Some(EndOfLineToken::Query),
_ => None,
}
}
pub fn take_endofline_tokens<'a>(
tokens: impl Iterator<Item = &'a str>,
eol: &mut EolState,
) -> (Vec<&'a str>, Vec<String>) {
let mut rest = Vec::new();
let mut replies = Vec::new();
for tok in tokens {
match parse_endofline_token(tok) {
Some(EndOfLineToken::Set(v)) => eol.endofline = v,
Some(EndOfLineToken::Toggle) => eol.endofline = !eol.endofline,
Some(EndOfLineToken::Query) => replies.push(format!(
"endofline={}",
if eol.endofline { "on" } else { "off" }
)),
None => rest.push(tok),
}
}
(rest, replies)
}
pub fn save_file_durable(
path: &std::path::Path,
body: &[u8],
trailing_nl: bool,
cwd: &std::path::Path,
) -> std::io::Result<()> {
use std::io::Write;
fn write_body(f: &mut std::fs::File, body: &[u8], trailing_nl: bool) -> std::io::Result<()> {
f.write_all(body)?;
if trailing_nl {
f.write_all(b"\n")?;
}
Ok(())
}
hjkl_engine::policy::check_fs_path(path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let target = if hjkl_engine::policy::fs_restricted() {
match hjkl_fs::resolve_under(cwd, path) {
Ok(resolved) => resolved,
Err(e) => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("{path}: {e}", path = path.display()),
));
}
}
} else {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
};
hjkl_fs::probe_writable_nofollow(&target)?;
hjkl_fs::write_atomic_with(&target, &hjkl_fs::WriteOptions::document(), |f| {
write_body(f, body, trailing_nl)
})
}
#[cfg(test)]
mod save_file_durable_tests {
use super::save_file_durable;
#[test]
fn overwrites_existing_file_atomically() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.txt");
std::fs::write(&p, "old contents\n").unwrap();
save_file_durable(&p, b"new contents", true, &std::env::current_dir().unwrap()).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "new contents\n");
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("hjkl-tmp"))
.collect();
assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}");
}
#[test]
fn creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("fresh.txt");
save_file_durable(&p, b"hello", true, &std::env::current_dir().unwrap()).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n");
}
#[cfg(unix)]
#[test]
fn preserves_symlink_and_updates_target() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.txt");
let link = dir.path().join("link.txt");
std::fs::write(&real, "old\n").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
save_file_durable(&link, b"new", true, &std::env::current_dir().unwrap()).unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "symlink was replaced");
assert_eq!(std::fs::read_link(&link).unwrap(), real);
assert_eq!(std::fs::read_to_string(&real).unwrap(), "new\n");
}
#[cfg(unix)]
#[test]
fn preserves_permission_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("script.sh");
std::fs::write(&p, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
save_file_durable(
&p,
b"#!/bin/sh\necho hi",
true,
&std::env::current_dir().unwrap(),
)
.unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "permission mode not preserved");
}
#[cfg(unix)]
#[test]
fn dangling_symlink_target_is_refused_by_nofollow_probe() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim.txt");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&victim, &link).unwrap();
assert!(
save_file_durable(&link, b"pwned", true, &std::env::current_dir().unwrap()).is_err(),
"write followed a symlink — O_NOFOLLOW probe lost"
);
assert!(!victim.exists(), "wrote through the symlink to its target");
}
#[cfg(unix)]
#[test]
fn readonly_target_still_errors() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("ro.txt");
std::fs::write(&p, "locked\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap();
assert!(save_file_durable(&p, b"nope", true, &std::env::current_dir().unwrap()).is_err());
assert_eq!(std::fs::read_to_string(&p).unwrap(), "locked\n");
}
}
#[cfg(test)]
mod eol_state_tests {
use super::{EndOfLineToken, EolState, parse_endofline_token, take_endofline_tokens};
struct Row {
loaded: &'static str,
fixeol_out: &'static str,
nofixeol_out: &'static str,
}
const ROWS: &[Row] = &[
Row {
loaded: "",
fixeol_out: "",
nofixeol_out: "",
},
Row {
loaded: "\n",
fixeol_out: "\n",
nofixeol_out: "\n",
},
Row {
loaded: "abc",
fixeol_out: "abc\n",
nofixeol_out: "abc",
},
Row {
loaded: "abc\n",
fixeol_out: "abc\n",
nofixeol_out: "abc\n",
},
Row {
loaded: "a\n\n",
fixeol_out: "a\n\n",
nofixeol_out: "a\n\n",
},
];
fn body_of(loaded: &str) -> &str {
loaded.strip_suffix('\n').unwrap_or(loaded)
}
fn written(loaded: &str, fixendofline: bool) -> String {
let body = body_of(loaded);
let mut out = body.to_string();
if EolState::from_loaded(loaded).trailing_newline(body, fixendofline) {
out.push('\n');
}
out
}
#[test]
fn reproduces_nvim_fixendofline_table() {
for row in ROWS {
let got = written(row.loaded, true);
assert_eq!(
got,
row.fixeol_out,
"fixendofline: loading {:?} then :w must write {:?} ({} bytes), got {:?}",
row.loaded,
row.fixeol_out,
row.fixeol_out.len(),
got
);
}
}
#[test]
fn reproduces_nvim_nofixendofline_table() {
for row in ROWS {
let got = written(row.loaded, false);
assert_eq!(
got,
row.nofixeol_out,
"nofixeol: loading {:?} then :w must write {:?} ({} bytes), got {:?}",
row.loaded,
row.nofixeol_out,
row.nofixeol_out.len(),
got
);
}
}
#[test]
fn lone_newline_file_is_not_truncated() {
assert_eq!(written("\n", true), "\n");
assert_eq!(written("\n", false), "\n");
assert_eq!(written("", true), "");
assert_eq!(written("", false), "");
}
#[test]
fn endofline_matches_nvim_load_flags() {
for (loaded, want) in [
("", true),
("\n", true),
("abc", false),
("abc\n", true),
("a\n\n", true),
] {
assert_eq!(
EolState::from_loaded(loaded).endofline,
want,
"&eol after loading {loaded:?}"
);
}
}
#[test]
fn zero_byte_file_writes_zero_bytes_under_every_flag_combination() {
let eol = EolState::from_loaded("");
for fixendofline in [true, false] {
for endofline in [true, false] {
let e = EolState { endofline, ..eol };
assert!(
!e.trailing_newline("", fixendofline),
"0-byte file must stay 0 bytes (eol={endofline}, fixeol={fixendofline})"
);
}
}
}
#[test]
fn editing_a_zero_byte_file_re_enables_the_trailing_newline() {
let eol = EolState::from_loaded("");
assert!(eol.trailing_newline("abc", true));
assert!(eol.trailing_newline("abc", false));
}
#[test]
fn new_buffer_default_matches_nvim_new_file() {
let eol = EolState::default();
assert!(!eol.trailing_newline("", true), "empty new file → 0 bytes");
assert!(!eol.trailing_newline("", false), "empty new file → 0 bytes");
assert!(eol.trailing_newline("abc", true));
assert!(
eol.trailing_newline("abc", false),
"new buffers have 'endofline' on, so nofixeol still writes it"
);
}
#[test]
fn endofline_override_controls_the_written_newline() {
let mut eol = EolState::from_loaded("abc\n");
eol.endofline = false;
assert!(
!eol.trailing_newline("abc", false),
"noeol + nofixeol → abc"
);
assert!(eol.trailing_newline("abc", true), "fixeol still adds it");
let mut eol = EolState::from_loaded("abc");
eol.endofline = true;
assert!(
eol.trailing_newline("abc", false),
"eol + nofixeol → abc\\n"
);
let mut eol = EolState::from_loaded("\n");
eol.endofline = false;
assert!(!eol.trailing_newline("", false));
}
#[test]
fn emptied_by_editing_diverges_from_nvim_by_design() {
let eol = EolState::from_loaded("a\n");
assert!(
eol.trailing_newline("", true),
"hjkl writes 1 byte here; nvim writes 0 — accepted divergence"
);
}
#[test]
fn parses_every_endofline_spelling() {
for name in ["endofline", "eol"] {
assert_eq!(
parse_endofline_token(name),
Some(EndOfLineToken::Set(true)),
"`:set {name}`"
);
assert_eq!(
parse_endofline_token(&format!("no{name}")),
Some(EndOfLineToken::Set(false)),
"`:set no{name}`"
);
assert_eq!(
parse_endofline_token(&format!("{name}!")),
Some(EndOfLineToken::Toggle),
"`:set {name}!`"
);
assert_eq!(
parse_endofline_token(&format!("{name}?")),
Some(EndOfLineToken::Query),
"`:set {name}?`"
);
}
for other in ["number", "fixendofline", "fixeol", "nofixeol", "eolx"] {
assert_eq!(parse_endofline_token(other), None, "`:set {other}`");
}
}
#[test]
fn take_tokens_consumes_only_endofline_and_reports_queries() {
let mut eol = EolState::from_loaded("abc\n");
let (rest, replies) =
take_endofline_tokens(["number", "noeol", "fixeol", "eol?"].into_iter(), &mut eol);
assert_eq!(
rest,
vec!["number", "fixeol"],
"non-eol tokens must flow through to hjkl-ex"
);
assert!(!eol.endofline, "`noeol` must clear 'endofline'");
assert_eq!(replies, vec!["endofline=off".to_string()]);
let (rest, replies) = take_endofline_tokens(["eol!"].into_iter(), &mut eol);
assert!(rest.is_empty());
assert!(replies.is_empty());
assert!(eol.endofline, "`eol!` must toggle 'endofline' back on");
}
}