feat(persistence): state foundation + saveplace + recentf (Arc 3 phase 1)
Framing: docs/persistence-framing.md. The four Rust primitives the
Lua-vs-Rust scout said were unavoidable, plus two Lua policy modules.
Rust:
- src/state.rs: state_dir(xdg,home) returning .../pmacs (generalizes
the baked-in history path). Deliberate empty-XDG fix (Q#PS2): a blank
XDG_STATE_HOME fell through to a RELATIVE pmacs/... path (a cwd-write
bug); now treated as absent so it falls to HOME. Confined key->file
store: validate_name rejects absolute / .. / empty / // / control
chars, plus a canonical-prefix belt; read/write/remove go through
file_io::save_atomic, never raw io.open. A PMACS_STATE_HOME override
lets CI / privacy-conscious users / integration harnesses redirect
all state to a scratch dir. History routed through the shared
resolver so it honors the override too.
- pmacs.state.{write,read,remove,path,available}: a no-op when the
state dir is unconfigured (cfg(test) / no HOME), so default-on
builtins write nothing in the lib suite. Configured once at startup
like history_dir, skipped under cfg(test).
- pmacs.editor.goto_byte / set_view_top: byte-exact restore (switch
zeroes the cursor).
Lua (builtin/runtime):
- saveplace.lua: record the active file's cursor+view_top on
before-save / before-quit; restore on after-load. LRU-capped places
state file. On by default; pmacs.saveplace.enable(false).
- recentf.lua: MRU record on after-load AND after-switch (re-visits
refresh the order); deduped/capped recentf file; a recent-files
command bound C-x C-r opens the minibuffer picker.
Tests: state.rs units (validate/resolve/round-trip/empty-XDG),
tests/persistence_acceptance.rs (state round-trip + confinement
rejections, inert-when-unconfigured, recentf MRU/dedup, saveplace
restore-on-reload, disable knob) injecting a tempdir state root. One
describe-hook test made robust to a builtin now subscribing to
buffer.before-save.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
349f13f674
commit
4dd4b9ab97
|
|
@ -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" }
|
||||
|
||||
|
|
@ -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 `<cursor> <view_top> <path>` 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
|
||||
-- "<cursor> <view_top> <path>"
|
||||
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)
|
||||
|
|
@ -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::<String>("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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Table> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
m.set(
|
||||
"write",
|
||||
lua.create_function(|lua, (name, content): (String, mlua::String)| {
|
||||
let Some(base) = lua.app_data_ref::<StateDir>() 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::<StateDir>() 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::<StateDir>() 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::<StateDir>() 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::<StateDir>().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(
|
||||
|
|
|
|||
|
|
@ -726,30 +726,25 @@ impl History {
|
|||
/// var is set.
|
||||
#[must_use]
|
||||
pub fn user_history_dir() -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf, &'static str> {
|
||||
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<Option<String>, 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<StateDir>();
|
||||
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<String>, 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::<StateDir>();
|
||||
let (avail, wrote, read): (bool, bool, Option<String>) = 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<String> = 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();
|
||||
}
|
||||
Loading…
Reference in New Issue