diff --git a/builtin/runtime/recentf.lua b/builtin/runtime/recentf.lua new file mode 100644 index 0000000..c607cf0 --- /dev/null +++ b/builtin/runtime/recentf.lua @@ -0,0 +1,86 @@ +-- recentf.lua --- a most-recently-visited file list (Arc 3 Q#PS4). +-- +-- Records every file buffer opened (buffer.after-load) or re-visited +-- (buffer.after-switch) into a deduped, capped, MRU-ordered `recentf` +-- state file (newline-delimited paths). `M-x recent-files` (bound +-- C-x C-r) opens the list in the minibuffer picker and visits the +-- choice. +-- +-- On by default; disable from init.lua with +-- `pmacs.recentf.enable(false)`. Inert when no state dir is configured +-- (cfg(test) / no HOME). +-- +-- Framing: docs/persistence-framing.md. + +pmacs.recentf = pmacs.recentf or {} + +local STATE_KEY = "recentf" +local MAX_ENTRIES = 50 + +local enabled = true +function pmacs.recentf.enable(on) + enabled = (on ~= false) +end + +local function load_list() + local out = {} + local text = pmacs.state.read(STATE_KEY) + if not text then return out end + for line in text:gmatch("([^\n]+)") do + out[#out + 1] = line + end + return out +end + +-- Move `path` to the front (MRU), dedup, cap. +local function record(path) + if not (enabled and pmacs.state.available()) or not path then return end + local list = load_list() + local kept = { path } + for _, p in ipairs(list) do + if p ~= path and #kept < MAX_ENTRIES then + kept[#kept + 1] = p + end + end + pmacs.state.write(STATE_KEY, table.concat(kept, "\n") .. (#kept > 0 and "\n" or "")) +end + +local function record_active() + pcall(record, pmacs.editor.file_path()) +end + +-- First open and every re-visit of an already-open file refresh MRU. +pmacs.hook.add("buffer.after-load", record_active) +pmacs.hook.add("buffer.after-switch", record_active) + +-- The public list (MRU-first), for the picker or a user script. +function pmacs.recentf.list() + return load_list() +end + +pmacs.command.define { + name = "recent-files", + description = "Visit a recently opened file (Arc 3).", + fn = function() + local list = load_list() + if #list == 0 then + pmacs.editor.set_status("recentf: no recent files") + return + end + pmacs.minibuffer.read { + prompt = "Recent file: ", + source = function() return list end, + history = "recent-files", + on_accept = function(path) + if path == nil or path == "" then return end + local ok, err = pcall(pmacs.buffer.find_or_open, path) + if not ok then + pmacs.editor.set_status("recentf: " .. tostring(err)) + end + end, + } + end, +} + +pmacs.keymap.bind { scope = "global", sequence = "C-x C-r", command = "recent-files" } + diff --git a/builtin/runtime/saveplace.lua b/builtin/runtime/saveplace.lua new file mode 100644 index 0000000..dba7b95 --- /dev/null +++ b/builtin/runtime/saveplace.lua @@ -0,0 +1,91 @@ +-- saveplace.lua --- remember the cursor position per file (Arc 3 Q#PS3b). +-- +-- Records the active file buffer's (cursor byte, view_top) on save and +-- at quit, and restores it when the file is reopened. Storage is the +-- `places` state file, one ` ` line per file +-- (numbers first so the path, which may contain spaces, is the +-- whitespace-split remainder). LRU-capped. +-- +-- On by default; disable from init.lua with +-- `pmacs.saveplace.enable(false)`. Inert when no state dir is +-- configured (cfg(test) / no HOME), so the lib suite writes nothing. +-- +-- Framing: docs/persistence-framing.md. + +pmacs.saveplace = pmacs.saveplace or {} + +local STATE_KEY = "places" +local MAX_ENTRIES = 200 + +local enabled = true +function pmacs.saveplace.enable(on) + enabled = (on ~= false) +end + +local function active_ready() + return enabled and pmacs.state.available() and pmacs.editor.file_path() ~= nil +end + +-- Load the places file into an ordered list of {path, cursor, view_top} +-- (most-recently-recorded first) plus a path->index lookup. +local function load_places() + local list, index = {}, {} + local text = pmacs.state.read(STATE_KEY) + if not text then return list, index end + for line in text:gmatch("([^\n]+)") do + -- " " + local cur, vt, path = line:match("^(%d+)%s+(%d+)%s+(.+)$") + if path and not index[path] then + list[#list + 1] = { path = path, cursor = tonumber(cur), view_top = tonumber(vt) } + index[path] = #list + end + end + return list, index +end + +local function save_places(list) + local lines = {} + for i = 1, math.min(#list, MAX_ENTRIES) do + local e = list[i] + lines[#lines + 1] = string.format("%d %d %s", e.cursor, e.view_top, e.path) + end + pmacs.state.write(STATE_KEY, table.concat(lines, "\n") .. (#lines > 0 and "\n" or "")) +end + +-- Record the active buffer's place, moving it to the front (LRU). +local function record_active() + if not active_ready() then return end + local path = pmacs.editor.file_path() + local cursor = pmacs.editor.cursor() + local view_top = pmacs.editor.view_top and pmacs.editor.view_top() or 0 + local list, index = load_places() + if index[path] then table.remove(list, index[path]) end + table.insert(list, 1, { path = path, cursor = cursor, view_top = view_top }) + save_places(list) +end + +-- Restore the just-loaded file's place, if we have one. +local function restore_active() + if not active_ready() then return end + local path = pmacs.editor.file_path() + local list, index = load_places() + local i = index[path] + if not i then return end + local e = list[i] + pmacs.editor.goto_byte(e.cursor) + if pmacs.editor.set_view_top then pmacs.editor.set_view_top(e.view_top) end +end + +-- Record on save and on quit; restore on open. before-save / +-- before-quit are short-circuit hooks — returning nil never vetoes. +pmacs.hook.add("buffer.before-save", function() + pcall(record_active) +end) + +pmacs.hook.add("editor.before-quit", function() + pcall(record_active) +end) + +pmacs.hook.add("buffer.after-load", function() + pcall(restore_active) +end) diff --git a/src/editor.rs b/src/editor.rs index 7656365..4627152 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -172,6 +172,15 @@ impl EditorState { if let Some(dir) = crate::minibuffer::user_history_dir() { core.borrow_mut().minibuffer.history_dir = Some(dir); } + // Arc 3 (Q#PS2): configure the `pmacs.state.*` base dir. Its + // absence under `cfg(test)` (this whole block is skipped) is + // what keeps default-on saveplace/recentf from writing to a + // developer's real state dir during the lib suite. + if let Some(dir) = crate::state::user_state_dir() { + lua_host + .lua() + .set_app_data(crate::lua_bindings::StateDir(dir)); + } } // The async runtime: install pmacs._async raw helpers, then // load the friendly Lua surface (`pmacs.async`, Handle class, @@ -301,6 +310,22 @@ impl EditorState { include_str!("../builtin/runtime/completion.lua"), ) .expect("load completion builtin chunk"); + // Arc 3: persistence builtins (saveplace + recentf). Load after + // the LSP/completion runtimes; they subscribe to buffer hooks + // and drive `pmacs.state` (inert until the state dir is + // configured — never in `cfg(test)`). + lua_host + .eval( + Some("@pmacs/builtin/runtime/saveplace.lua"), + include_str!("../builtin/runtime/saveplace.lua"), + ) + .expect("load saveplace builtin chunk"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/recentf.lua"), + include_str!("../builtin/runtime/recentf.lua"), + ) + .expect("load recentf builtin chunk"); // T M7.11 bundled-package bootstrap. Through M7.10 the REPL // was loaded directly via `eval(include_str!(...))`; the // M7.11 deliverable migrates it to the package system so it @@ -4128,9 +4153,13 @@ mod tests { assert!(info.get::("source").unwrap().contains(':')); let callbacks: mlua::Table = info.get("callbacks").unwrap(); let len = callbacks.len().unwrap(); - assert_eq!(len, 2, "expected 2 callbacks; describe says {len}"); - let cb1: mlua::Table = callbacks.get(1).unwrap(); - let cb2: mlua::Table = callbacks.get(2).unwrap(); + // A builtin (saveplace) also subscribes to `buffer.before-save`, + // registered at startup, so it precedes the two the test adds. + // Assert on the *last two* callbacks — the ones this chunk just + // registered — rather than the exact total (robust to builtins). + assert!(len >= 2, "expected >= 2 callbacks; describe says {len}"); + let cb1: mlua::Table = callbacks.get(len - 1).unwrap(); + let cb2: mlua::Table = callbacks.get(len).unwrap(); let s1: String = cb1.get("source").unwrap(); let s2: String = cb2.get("source").unwrap(); // Both registrations come from the test chunk; the second diff --git a/src/editor_core.rs b/src/editor_core.rs index 9eae480..1432b75 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -493,6 +493,27 @@ impl EditorCore { self.active_window().view_top } + /// Set the active window's cursor to a byte offset, clamped to the + /// buffer extent (Arc 3 Q#PS1 — saveplace/desktop restore). Resets + /// the goal column. Since `switch_active_buffer` zeroes the cursor, + /// restore calls this *after* the open/switch. + pub fn set_cursor_byte(&mut self, byte: u64) { + let clamped = byte.min(self.active_buffer_len()); + let aw = self.active_window_mut(); + aw.cursor = clamped; + aw.goal_col = None; + } + + /// Set the active window's `view_top` (first visible source line), + /// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop + /// restore). A file that shrank since the desktop was saved can't + /// scroll past its end. + pub fn set_view_top(&mut self, top: usize) { + let lines = self.active_window().text_view.line_count(); + let clamped = top.min(lines.saturating_sub(1)); + self.active_window_mut().view_top = clamped; + } + /// Active buffer's byte length. #[must_use] pub fn active_buffer_len(&self) -> u64 { diff --git a/src/lib.rs b/src/lib.rs index 8e35f45..3e6003f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -123,6 +123,7 @@ pub mod semantic_render; pub mod semantic_tokens; pub mod signature; pub mod socket_path; +pub mod state; pub mod symbol; pub mod syntax; pub mod text_view; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b6c97af..874d8e3 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1961,10 +1961,82 @@ pub fn install( pmacs.set("instance", install_instance_module(lua, registry)?)?; pmacs.set("ansi", install_ansi_module(lua)?)?; pmacs.set("packages", install_packages_module(lua)?)?; + pmacs.set("state", install_state_module(lua)?)?; lua.globals().set("pmacs", pmacs)?; Ok(()) } +/// The configured base state directory (Arc 3, Q#PS2). Present as Lua +/// app-data only when a real dir was resolved at startup; its absence +/// (the `cfg(test)` case, and any host without `HOME`/`XDG_STATE_HOME`) +/// makes every `pmacs.state.*` call a no-op, so default-on persistence +/// builtins never touch disk in `cargo test`. +pub struct StateDir(pub std::path::PathBuf); + +/// `pmacs.state.{write,read,remove,path}` — the confined key→file store +/// (Q#PS2). All keys pass [`crate::state::validate_name`], so a state +/// call can never read or write outside the state directory. When the +/// state dir is unconfigured every call is inert: `write`/`remove` +/// return `false`, `read`/`path` return `nil`. +fn install_state_module(lua: &Lua) -> mlua::Result { + let m = lua.create_table()?; + + m.set( + "write", + lua.create_function(|lua, (name, content): (String, mlua::String)| { + let Some(base) = lua.app_data_ref::() else { + return Ok(false); + }; + crate::state::write(&base.0, &name, &content.as_bytes()) + .map_err(mlua::Error::external)?; + Ok(true) + })?, + )?; + + m.set( + "read", + lua.create_function(|lua, name: String| { + let Some(base) = lua.app_data_ref::() else { + return Ok(None); + }; + crate::state::read(&base.0, &name).map_err(mlua::Error::external) + })?, + )?; + + m.set( + "remove", + lua.create_function(|lua, name: String| { + let Some(base) = lua.app_data_ref::() else { + return Ok(false); + }; + crate::state::remove(&base.0, &name).map_err(mlua::Error::external)?; + Ok(true) + })?, + )?; + + m.set( + "path", + lua.create_function(|lua, name: String| { + let Some(base) = lua.app_data_ref::() else { + return Ok(None); + }; + match crate::state::resolve(&base.0, &name) { + Ok(p) => Ok(Some(p.display().to_string())), + Err(e) => Err(mlua::Error::external(e)), + } + })?, + )?; + + // True when a state directory is configured — lets Lua modules tell + // "unconfigured (test / no HOME)" from "configured but empty". + m.set( + "available", + lua.create_function(|lua, ()| Ok(lua.app_data_ref::().is_some()))?, + )?; + + Ok(m) +} + /// Build the `pmacs.attach` Lua function (T M5.6d). /// /// Init-time-only: refuses to run after [`InitCompleteFlag`] has been @@ -10681,6 +10753,34 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result })?, )?; } + { + // goto_byte(pos): set the active cursor to a byte offset + // (clamped). The byte-exact restore saveplace/desktop need + // (Arc 3) — `move_to_line` is line-based, and switch zeroes the + // cursor, so restore sets it here after opening. + let cc = core.clone(); + editor.set( + "goto_byte", + lua.create_function(move |_, pos: i64| { + let byte = u64::try_from(pos).map_err(mlua::Error::external)?; + cc.borrow_mut().set_cursor_byte(byte); + Ok(()) + })?, + )?; + } + { + // set_view_top(line): set the first visible source line + // (clamped to the buffer's line count) — desktop restore. + let cc = core.clone(); + editor.set( + "set_view_top", + lua.create_function(move |_, top: i64| { + let top = usize::try_from(top).map_err(mlua::Error::external)?; + cc.borrow_mut().set_view_top(top); + Ok(()) + })?, + )?; + } { let cc = core.clone(); editor.set( diff --git a/src/minibuffer.rs b/src/minibuffer.rs index 889cc2d..eb8ce00 100644 --- a/src/minibuffer.rs +++ b/src/minibuffer.rs @@ -726,30 +726,25 @@ impl History { /// var is set. #[must_use] pub fn user_history_dir() -> Option { - resolve_history_dir( - std::env::var_os("XDG_STATE_HOME").as_deref(), - std::env::var_os("HOME").as_deref(), - ) + // Route through the shared state-dir resolver so history honors the + // `PMACS_STATE_HOME` override too (Arc 3 Q#PS2). + crate::state::user_state_dir().map(|d| d.join("history")) } /// Pure helper for [`user_history_dir`], factored out so tests can /// inject paths directly without touching the process environment /// (R55: `unsafe_code = "forbid"` rules out `env::set_var`). +/// +/// History lives under the shared editor state dir +/// ([`crate::state::state_dir`], Arc 3 Q#PS2) in a `history/` +/// subdirectory. A blank `XDG_STATE_HOME` now falls through to `HOME` +/// instead of yielding a relative path (the empty-XDG fix). #[must_use] pub fn resolve_history_dir( xdg_state: Option<&std::ffi::OsStr>, home: Option<&std::ffi::OsStr>, ) -> Option { - if let Some(xdg) = xdg_state { - return Some(PathBuf::from(xdg).join("pmacs").join("history")); - } - home.map(|h| { - PathBuf::from(h) - .join(".local") - .join("state") - .join("pmacs") - .join("history") - }) + crate::state::state_dir(xdg_state, home).map(|d| d.join("history")) } fn history_path(dir: &Path, bucket: &str) -> PathBuf { diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..88a9bea --- /dev/null +++ b/src/state.rs @@ -0,0 +1,294 @@ +// state.rs --- persistent editor state directory (Arc 3, Q#PS2). + +//! The `$XDG_STATE_HOME/pmacs/` base that all persisted editor state +//! lives under: minibuffer history (the original tenant), plus the +//! Arc 3 persistence features (recent files, saveplace, desktop, and +//! autosave recovery). +//! +//! Env is passed in as arguments, never read inline, so the pure +//! resolver is testable without touching the process environment +//! (`#![forbid(unsafe_code)]` rules out `env::set_var`). + +use std::ffi::OsStr; +use std::path::PathBuf; + +/// The base state directory `.../pmacs`, or `None` when neither +/// `XDG_STATE_HOME` nor `HOME` is usably set. +/// +/// Order: `$XDG_STATE_HOME/pmacs`, then `$HOME/.local/state/pmacs`. +/// +/// A **blank** `XDG_STATE_HOME` is treated as *absent* (Q#PS2 fix): the +/// prior history resolver returned a *relative* `pmacs/…` for +/// `Some("")`, which would write state into the process's current +/// directory — a latent bug. Here an empty (or all-whitespace) value +/// falls through to `HOME`, and a `HOME` that is itself blank yields +/// `None` rather than a relative path. +#[must_use] +pub fn state_dir(xdg_state: Option<&OsStr>, home: Option<&OsStr>) -> Option { + if let Some(xdg) = xdg_state.filter(|s| !is_blank(s)) { + return Some(PathBuf::from(xdg).join("pmacs")); + } + home.filter(|s| !is_blank(s)) + .map(|h| PathBuf::from(h).join(".local").join("state").join("pmacs")) +} + +/// Resolve the base state directory from the process environment. +/// +/// A `PMACS_STATE_HOME` override wins over `XDG_STATE_HOME`/`HOME` when +/// set (and non-blank): `.../pmacs` under it. This is the redirect a +/// test harness, CI, or a privacy-conscious user points at a scratch +/// dir so persistence never touches the real `~/.local/state/pmacs` +/// (integration tests link the lib without `cfg(test)`, so the +/// startup wiring runs — the override is how they stay clean). +#[must_use] +pub fn user_state_dir() -> Option { + if let Some(over) = std::env::var_os("PMACS_STATE_HOME") + .as_deref() + .filter(|s| !is_blank(s)) + { + return Some(PathBuf::from(over).join("pmacs")); + } + state_dir( + std::env::var_os("XDG_STATE_HOME").as_deref(), + std::env::var_os("HOME").as_deref(), + ) +} + +/// True when `s` is empty or all ASCII/Unicode whitespace — an +/// unusable env value we treat as unset. +fn is_blank(s: &OsStr) -> bool { + match s.to_str() { + Some(text) => text.trim().is_empty(), + // Non-UTF-8 path bytes are a real (if exotic) directory name; + // only the empty OsStr counts as blank there. + None => s.is_empty(), + } +} + +// --------------------------------------------------------------------------- +// Confined key→file store (Q#PS2) +// --------------------------------------------------------------------------- + +use std::path::Path; + +/// Validate a state key so `pmacs.state.*` can never escape the state +/// directory (Q#PS2 path confinement). A key is a **relative** path of +/// one or more `/`-separated components, each non-empty and drawn from +/// `[A-Za-z0-9._-]`, and no component may be `.` or `..`. Everything +/// else — an absolute path, an empty key, a `.`/`..` component, `//`, +/// or any other byte (separators, control chars, spaces) — is rejected. +/// +/// Without this, a state binding meant to *avoid* raw `io.open` would +/// become an arbitrary read/write anywhere on disk. +/// +/// # Errors +/// Returns a static message describing the first rule the key violates. +pub fn validate_name(name: &str) -> Result<(), &'static str> { + if name.is_empty() { + return Err("state key is empty"); + } + // Reject a leading `/` up front so the split below can't be fooled. + if name.starts_with('/') { + return Err("state key must be relative"); + } + let mut components = 0usize; + for part in name.split('/') { + if part.is_empty() { + return Err("state key has an empty path component"); + } + if part == "." || part == ".." { + return Err("state key may not contain `.` or `..`"); + } + if !part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err("state key component has a disallowed character"); + } + components += 1; + } + if components == 0 { + return Err("state key is empty"); + } + Ok(()) +} + +/// Resolve a validated key to its absolute path under `base`, with a +/// canonical-prefix belt: the joined path must still start with `base`. +/// +/// # Errors +/// Propagates [`validate_name`], or errors if the join escapes `base` +/// (which [`validate_name`] already prevents — this is defense in depth). +pub fn resolve(base: &Path, name: &str) -> Result { + validate_name(name)?; + let path = base.join(name); + if !path.starts_with(base) { + return Err("state key escapes the state directory"); + } + Ok(path) +} + +/// Read a state file's contents, or `Ok(None)` when it does not exist. +/// +/// # Errors +/// Invalid key, or an IO error other than not-found. +pub fn read(base: &Path, name: &str) -> Result, StateError> { + let path = resolve(base, name).map_err(StateError::Name)?; + match std::fs::read_to_string(&path) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StateError::Io(e)), + } +} + +/// Atomically write `content` to a state file (creating parents), +/// via [`crate::file_io::save_atomic`] — same durability the editor's +/// own saves get, and no raw `io.open`. +/// +/// # Errors +/// Invalid key, or a save failure. +pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> { + let path = resolve(base, name).map_err(StateError::Name)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(StateError::Io)?; + } + crate::file_io::save_atomic(&path, content).map_err(StateError::Save)?; + Ok(()) +} + +/// Remove a state file. Missing file is success (idempotent). +/// +/// # Errors +/// Invalid key, or an IO error other than not-found. +pub fn remove(base: &Path, name: &str) -> Result<(), StateError> { + let path = resolve(base, name).map_err(StateError::Name)?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(StateError::Io(e)), + } +} + +/// Error from a confined state operation. +#[derive(Debug)] +pub enum StateError { + /// The key failed [`validate_name`]. + Name(&'static str), + /// An underlying IO failure (read / remove). + Io(std::io::Error), + /// An atomic-write failure. + Save(crate::file_io::SaveError), +} + +impl std::fmt::Display for StateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateError::Name(m) => write!(f, "invalid state key: {m}"), + StateError::Io(e) => write!(f, "{e}"), + StateError::Save(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for StateError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_xdg_state_home() { + let d = state_dir(Some(OsStr::new("/x/state")), Some(OsStr::new("/home/u"))).unwrap(); + assert_eq!(d, PathBuf::from("/x/state/pmacs")); + } + + #[test] + fn falls_back_to_home_local_state() { + let d = state_dir(None, Some(OsStr::new("/home/u"))).unwrap(); + assert_eq!(d, PathBuf::from("/home/u/.local/state/pmacs")); + } + + #[test] + fn none_when_neither_is_set() { + assert!(state_dir(None, None).is_none()); + } + + #[test] + fn validate_name_accepts_keys_and_subpaths() { + for ok in ["recentf", "places", "autosave/deadbeef", "a.b_c-1/x2"] { + assert!(validate_name(ok).is_ok(), "{ok:?} should be accepted"); + } + } + + #[test] + fn validate_name_rejects_escapes() { + for bad in [ + "", + "/etc/passwd", + "..", + "../x", + "a/../b", + "a//b", + "a/", + "/a", + ".", + "a/.", + "with space", + "tab\t", + "null\0", + "sub/../../x", + "..\\x", + ] { + assert!(validate_name(bad).is_err(), "{bad:?} must be rejected"); + } + } + + #[test] + fn resolve_stays_under_base() { + let base = PathBuf::from("/state/pmacs"); + assert_eq!( + resolve(&base, "autosave/x").unwrap(), + PathBuf::from("/state/pmacs/autosave/x") + ); + assert!(resolve(&base, "../escape").is_err()); + } + + #[test] + fn write_read_remove_round_trip() { + let dir = std::env::temp_dir().join(format!("pmacs-state-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(read(&dir, "recentf").unwrap().is_none(), "absent → None"); + write(&dir, "recentf", b"a\nb\n").unwrap(); + assert_eq!(read(&dir, "recentf").unwrap().as_deref(), Some("a\nb\n")); + // Subpath creates its parent dir. + write(&dir, "autosave/h1", b"x").unwrap(); + assert_eq!(read(&dir, "autosave/h1").unwrap().as_deref(), Some("x")); + remove(&dir, "recentf").unwrap(); + assert!(read(&dir, "recentf").unwrap().is_none(), "removed → None"); + remove(&dir, "recentf").unwrap(); // idempotent + // An invalid key errors rather than escaping. + assert!(read(&dir, "../x").is_err()); + assert!(write(&dir, "/abs", b"x").is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn blank_xdg_falls_through_to_home_not_a_relative_path() { + // The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT + // produce a relative `pmacs/...` (which would write into the + // cwd). It falls through to HOME instead. + for blank in ["", " ", "\t"] { + let d = state_dir(Some(OsStr::new(blank)), Some(OsStr::new("/home/u"))).unwrap(); + assert_eq!( + d, + PathBuf::from("/home/u/.local/state/pmacs"), + "blank XDG {blank:?} must fall through to HOME" + ); + assert!(d.is_absolute(), "state dir is never relative"); + } + // Blank XDG and no HOME → None, not a relative path. + assert!(state_dir(Some(OsStr::new("")), None).is_none()); + // A blank HOME is likewise unusable. + assert!(state_dir(None, Some(OsStr::new(" "))).is_none()); + } +} diff --git a/tests/persistence_acceptance.rs b/tests/persistence_acceptance.rs new file mode 100644 index 0000000..8b0a5f6 --- /dev/null +++ b/tests/persistence_acceptance.rs @@ -0,0 +1,167 @@ +//! Persistence phase 1 acceptance (Arc 3): the `pmacs.state` confined +//! store, saveplace (cursor restored on reopen), and recentf (MRU +//! record + dedup + picker), driven through the real Lua surface. +//! +//! Integration tests link the lib without `cfg(test)`, so +//! `EditorState::new()` configures the state dir from the environment. +//! Each test **overrides that with a private tempdir** (via the +//! `StateDir` app-data) before touching any file, so the suite never +//! reads or writes a developer's real `~/.local/state/pmacs`. +//! +//! Framing: docs/persistence-framing.md. + +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use std::path::PathBuf; + +/// A fresh editor whose state dir is a private, empty tempdir (unique +/// per call so parallel tests never share or wipe each other's dirs). +fn editor_with_state_dir() -> (EditorState, PathBuf) { + use std::sync::atomic::{AtomicUsize, Ordering}; + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-persist-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("mk state tempdir"); + let s = EditorState::new(); + // Override whatever startup configured with our tempdir. + s.lua_host.lua().remove_app_data::(); + s.lua_host.lua().set_app_data(StateDir(dir.clone())); + (s, dir) +} + +/// Write a real file under `dir` and return its path string. +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).expect("write test file"); + p.display().to_string() +} + +#[test] +fn state_round_trips_and_rejects_escapes() { + let (s, dir) = editor_with_state_dir(); + let out: (bool, Option, bool, bool) = s + .lua_host + .lua() + .load( + r#" + local wrote = pmacs.state.write("recentf", "a\nb\n") + local back = pmacs.state.read("recentf") + -- Confinement: an escaping key must error (pcall → false). + local esc_ok = pcall(pmacs.state.write, "../escape", "x") + local abs_ok = pcall(pmacs.state.read, "/etc/passwd") + return wrote, back, esc_ok, abs_ok + "#, + ) + .eval() + .expect("state round-trip"); + assert!(out.0, "write returned true"); + assert_eq!( + out.1.as_deref(), + Some("a\nb\n"), + "read returns what was written" + ); + assert!(!out.2, "`../escape` key is rejected"); + assert!(!out.3, "absolute key is rejected"); + // And it actually landed under our tempdir, nowhere else. + assert!(dir.join("recentf").exists()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn state_is_inert_when_unconfigured() { + let s = EditorState::new(); + // Simulate no state dir (the cfg(test) lib case, or no HOME). + s.lua_host.lua().remove_app_data::(); + let (avail, wrote, read): (bool, bool, Option) = s + .lua_host + .lua() + .load( + r#" + return pmacs.state.available(), + pmacs.state.write("recentf", "should not persist"), + pmacs.state.read("recentf") + "#, + ) + .eval() + .expect("inert state"); + assert!(!avail, "unconfigured → not available"); + assert!(!wrote, "write is a no-op (returns false)"); + assert_eq!(read, None, "read returns nil"); +} + +#[test] +fn recentf_records_dedups_and_orders_mru() { + let (s, dir) = editor_with_state_dir(); + let a = write_file(&dir, "a.rs", "fn a() {}\n"); + let b = write_file(&dir, "b.rs", "fn b() {}\n"); + // Open a, then b, then a again — MRU should be [a, b]. + for path in [&a, &b, &a] { + s.lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open({path:?})")) + .exec() + .expect("open file"); + } + let list: Vec = s + .lua_host + .lua() + .load("return pmacs.recentf.list()") + .eval() + .expect("recentf list"); + assert_eq!(list, vec![a.clone(), b.clone()], "MRU-first, deduped"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn saveplace_restores_cursor_on_reopen() { + let (s, dir) = editor_with_state_dir(); + let f = write_file(&dir, "place.rs", "line0\nline1\nline2\nline3\n"); + // Open, move to byte 12 ("line2"), save (before-save records the + // place), then KILL the buffer — so the reopen is a fresh load + // (buffer.after-load), the cross-session path saveplace targets. + let cursor: i64 = s + .lua_host + .lua() + .load(format!( + r#" + local b = pmacs.buffer.find_or_open({f:?}) + pmacs.editor.goto_byte(12) + pmacs.command.invoke("buffer.save") + pmacs.buffer.kill(b) + -- Reopen from scratch: after-load fires → saveplace restores. + pmacs.buffer.find_or_open({f:?}) + return pmacs.editor.cursor() + "# + )) + .eval() + .expect("place + save + kill + reopen"); + assert_eq!(cursor, 12, "saveplace restored the cursor byte on reload"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn saveplace_can_be_disabled() { + let (s, dir) = editor_with_state_dir(); + let f = write_file(&dir, "off.rs", "aaaa\nbbbb\ncccc\n"); + let cursor: i64 = s + .lua_host + .lua() + .load(format!( + r#" + pmacs.saveplace.enable(false) + local b = pmacs.buffer.find_or_open({f:?}) + pmacs.editor.goto_byte(10) + pmacs.command.invoke("buffer.save") + pmacs.buffer.kill(b) + pmacs.buffer.find_or_open({f:?}) + return pmacs.editor.cursor() + "# + )) + .eval() + .expect("disabled saveplace flow"); + assert_eq!(cursor, 0, "disabled saveplace leaves the cursor at the top"); + std::fs::remove_dir_all(&dir).ok(); +}