use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use mlua::prelude::*;
use sha2::{Digest, Sha256};
pub type SnapshotStore = Arc<Mutex<HashMap<PathBuf, String>>>;
fn version_of(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
fn split_lines(content: &str) -> (Vec<&str>, bool) {
let trailing_newline = content.ends_with('\n');
let body = if trailing_newline {
&content[..content.len() - 1]
} else {
content
};
if body.is_empty() && trailing_newline {
return (vec![""], true);
}
(body.split('\n').collect(), trailing_newline)
}
fn join_lines(lines: &[String], trailing_newline: bool) -> String {
let mut out = lines.join("\n");
if trailing_newline {
out.push('\n');
}
out
}
struct Edit {
index: usize,
start_line: usize,
end_line: usize,
replace: String,
}
async fn blocking<T, F>(op: &'static str, f: F) -> LuaResult<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| LuaError::external(format!("{op}: spawn_blocking: {e}")))
}
async fn read_to_string(path: String) -> LuaResult<String> {
blocking("fs.read", move || {
std::fs::read_to_string(&path).map_err(|e| format!("fs.edit: cannot read {path}: {e}"))
})
.await?
.map_err(LuaError::external)
}
async fn write_string(op: &'static str, path: String, content: String) -> LuaResult<()> {
blocking("fs.write", move || {
std::fs::write(&path, &content).map_err(|e| format!("{op}: cannot write {path}: {e}"))
})
.await?
.map_err(LuaError::external)
}
fn failure(lua: &Lua, reason: &str) -> LuaResult<LuaTable> {
let t = lua.create_table()?;
t.set("ok", false)?;
t.set("reason", reason)?;
Ok(t)
}
pub fn register(lua: &Lua, snapshots: SnapshotStore) -> LuaResult<()> {
let globals = lua.globals();
let std_tbl: LuaTable = globals.get("std")?;
let fs_tbl: LuaTable = std_tbl.get("fs")?;
fs_tbl.set(
"read_versioned",
lua.create_async_function(|lua: Lua, path: String| async move {
let content = read_to_string(path).await?;
let (lines, _) = split_lines(&content);
let t = lua.create_table()?;
t.set("content", content.as_str())?;
t.set("lines", lines.len())?;
t.set("version", version_of(&content))?;
Ok(t)
})?,
)?;
let edit_snapshots = Arc::clone(&snapshots);
fs_tbl.set(
"edit",
lua.create_async_function(move |lua: Lua, (path, opts): (String, LuaTable)| {
let snapshots = Arc::clone(&edit_snapshots);
async move {
let content = read_to_string(path.clone()).await?;
let current_version = version_of(&content);
if let Ok(base) = opts.get::<String>("base") {
if !base.is_empty() && base != current_version {
let t = failure(&lua, "stale_base")?;
t.set("expected_version", base)?;
t.set("actual_version", current_version)?;
return Ok(t);
}
}
let edits_tbl: LuaTable = opts.get("edits")?;
let (lines, trailing_newline) = split_lines(&content);
let mut edits: Vec<Edit> = Vec::new();
for (i, entry) in edits_tbl.sequence_values::<LuaTable>().enumerate() {
let entry = entry?;
let start_line: usize = entry.get("start_line")?;
let end_line: usize = entry.get("end_line")?;
let expect: String = entry.get("expect")?;
let replace: String = entry.get("replace")?;
if start_line == 0 || end_line < start_line {
let t = failure(&lua, "bad_range")?;
t.set("edit_index", i + 1)?;
t.set("start_line", start_line)?;
t.set("end_line", end_line)?;
return Ok(t);
}
if end_line > lines.len() {
let t = failure(&lua, "out_of_range")?;
t.set("edit_index", i + 1)?;
t.set("end_line", end_line)?;
t.set("file_lines", lines.len())?;
return Ok(t);
}
let actual = lines[start_line - 1..end_line].join("\n");
if actual != expect {
let t = failure(&lua, "expect_mismatch")?;
t.set("edit_index", i + 1)?;
t.set("start_line", start_line)?;
t.set("end_line", end_line)?;
t.set("actual", actual)?;
return Ok(t);
}
edits.push(Edit {
index: i + 1,
start_line,
end_line,
replace,
});
}
if edits.is_empty() {
let t = failure(&lua, "no_edits")?;
return Ok(t);
}
let mut ordered: Vec<&Edit> = edits.iter().collect();
ordered.sort_by_key(|e| e.start_line);
for pair in ordered.windows(2) {
if pair[0].end_line >= pair[1].start_line {
let t = failure(&lua, "overlapping_edits")?;
t.set("edit_index", pair[0].index)?;
t.set("other_edit_index", pair[1].index)?;
return Ok(t);
}
}
let mut out: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
for e in ordered.iter().rev() {
let replacement: Vec<String> = if e.replace.is_empty() {
Vec::new()
} else {
e.replace.split('\n').map(|s| s.to_string()).collect()
};
out.splice(e.start_line - 1..e.end_line, replacement);
}
let new_content = join_lines(&out, trailing_newline);
let version = version_of(&new_content);
write_string("fs.edit", path.clone(), new_content).await?;
if let Ok(mut map) = snapshots.lock() {
map.insert(PathBuf::from(&path), content);
}
let t = lua.create_table()?;
t.set("ok", true)?;
t.set("applied", edits.len())?;
t.set("version", version)?;
Ok(t)
}
})?,
)?;
let rollback_snapshots = Arc::clone(&snapshots);
fs_tbl.set(
"rollback",
lua.create_async_function(move |lua: Lua, path: String| {
let snapshots = Arc::clone(&rollback_snapshots);
async move {
let key = PathBuf::from(&path);
let previous = snapshots.lock().ok().and_then(|mut map| map.remove(&key));
match previous {
Some(content) => {
let version = version_of(&content);
write_string("fs.rollback", path, content).await?;
let t = lua.create_table()?;
t.set("ok", true)?;
t.set("version", version)?;
Ok(t)
}
None => failure(&lua, "no_snapshot"),
}
}
})?,
)?;
lua.load(include_str!("fs_tools.lua"))
.set_name("std.fs.register_tools")
.exec()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn lines_of(s: &str) -> Vec<String> {
split_lines(s).0.iter().map(|x| x.to_string()).collect()
}
#[test]
fn split_and_join_round_trip() {
for s in ["a\nb\nc\n", "a\nb\nc", "", "\n", "single"] {
let (lines, nl) = split_lines(s);
let owned: Vec<String> = lines.iter().map(|x| x.to_string()).collect();
assert_eq!(join_lines(&owned, nl), s, "round trip failed for {s:?}");
}
}
#[test]
fn version_changes_with_content() {
assert_ne!(version_of("a"), version_of("b"));
assert_eq!(version_of("a"), version_of("a"));
}
#[test]
fn lines_helper_counts_final_newline_once() {
assert_eq!(lines_of("a\nb\n").len(), 2);
assert_eq!(lines_of("a\nb").len(), 2);
}
#[cfg(target_os = "linux")]
#[test]
fn a_slow_edit_does_not_block_another_coroutine_on_the_same_vm() {
use std::os::unix::ffi::OsStrExt;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
const HELD: Duration = Duration::from_millis(300);
const TICK: Duration = Duration::from_millis(5);
const AT_LEAST: usize = 5;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime for the VM to yield into");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slow.txt");
{
let c = std::ffi::CString::new(path.as_os_str().as_bytes())
.expect("a path with no interior NUL");
let rc = unsafe { libc::mkfifo(c.as_ptr(), 0o600) };
assert_eq!(rc, 0, "mkfifo: {}", std::io::Error::last_os_error());
}
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
std::thread::sleep(HELD);
std::fs::write(&writer_path, "alpha\n").expect("feed the fifo");
});
let lua = Lua::new();
let std_tbl = lua.create_table().expect("std table");
std_tbl
.set("fs", lua.create_table().expect("fs table"))
.expect("set std.fs");
lua.globals().set("std", std_tbl).expect("set std");
register(&lua, SnapshotStore::default()).expect("register the fs primitives");
lua.globals()
.set("PATH", path.to_string_lossy().as_ref())
.expect("set PATH");
let ticks = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&ticks);
let tick = lua
.create_async_function(move |_, ()| {
let counter = Arc::clone(&counter);
async move {
tokio::time::sleep(TICK).await;
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
}
})
.expect("create tick");
lua.globals().set("tick", tick).expect("set tick");
let counter = Arc::clone(&ticks);
let read_ticks = lua
.create_function(move |_, ()| Ok(counter.load(Ordering::Relaxed)))
.expect("create ticks");
lua.globals().set("ticks", read_ticks).expect("set ticks");
let during: usize = rt.block_on(async {
let editor = lua
.load(
r#"
local before = ticks()
local r = std.fs.edit(PATH, {
base = "0000000000000000",
edits = {
{ start_line = 1, end_line = 1, expect = "alpha", replace = "ALPHA" },
},
})
assert(r.ok == false, "the edit should have been refused")
assert(r.reason == "stale_base", "refused for: " .. tostring(r.reason))
return ticks() - before
"#,
)
.eval_async::<usize>();
let ticker = lua.load(r#"for _ = 1, 200 do tick() end"#).exec_async();
let (edited, _) = tokio::join!(editor, ticker);
edited.expect("the edit eventually returns")
});
writer.join().expect("the writer thread");
assert!(
during >= AT_LEAST,
"the VM stopped while the read was waiting: only {during} tick(s) ran"
);
}
}