Merge pull request #98 from levineuwirth/session-persistence-p1

feat(persistence): state foundation + saveplace + recentf — Arc 3 phase 1
This commit is contained in:
Levi Neuwirth 2026-07-08 18:30:51 -04:00 committed by GitHub
commit d9ae307813
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1216 additions and 27 deletions

View File

@ -0,0 +1,85 @@
-- 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" }

View File

@ -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()
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)
pmacs.editor.set_view_top(e.view_top)
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)

295
docs/persistence-framing.md Normal file
View File

@ -0,0 +1,295 @@
# Persistence — framing (Arc 3)
pmacs forgets everything on exit except minibuffer history. Reopen a
file and the cursor is at the top; restart and your open buffers,
splits, and recently-visited files are gone; a crash loses unsaved
work with no recovery. This arc adds the four classic session-
persistence features — **saveplace** (cursor memory), **recentf**
(recent files), **desktop-save** (buffers + layout + positions), and
**autosave + crash recovery** — generalizing the one persistence
pattern that already works (`$XDG_STATE_HOME/pmacs/` history).
Roadmap: `docs/roadmap-2026-07.md` Arc 3, including its open question
"what is a 'session' in a daemon world" (Q#PS6).
## What already exists (verified)
- **State-dir precedent** (`src/minibuffer.rs`): `resolve_history_dir`
`$XDG_STATE_HOME/pmacs/history` (fallback
`~/.local/state/pmacs/history`); `load_history_file` /
`append_history_file` (newline-delimited, `create_dir_all` on
write). Env is passed as args, never read inline — the
`#![forbid(unsafe_code)]` discipline. **But the `/history` segment
is baked in; there is no generic `state_dir()`** returning the bare
`.../pmacs/` base (Q#PS2).
- **Buffer/path model**: `Buffer.file_path: Option<PathBuf>` (`None`
for scratch/unsaved) + `file_meta: Option<FileMeta>`; registry
`ids()` is stable insertion order. Open flow is Lua
(`pmacs.buffer.find_or_open` → `file_io::load_file`
`set_buffer_path`/`set_buffer_meta` → `buffer.after-load`). **No
`is_special` flag** — file buffers are just `file_path().is_some()`.
**Gap**: `pmacs.buffer.list()` yields ids with no per-id
`file_path()` method; only `pmacs.editor.file_path()` (active
buffer) reads a path.
- **Layout** (`src/window.rs`): `LayoutNode = Leaf(WindowId) |
Split{orientation, weights: Vec<u32>, children}`; ratios are the
integer weights (survive resize). **Not serde; `WindowId` is a
process counter (not restart-stable)** — a saved layout must key
leaves by *path + cursor + view_top* and rebuild structurally. Lua
window API is splits + switch only (can't read/build an arbitrary
tree).
- **Cursor/view**: per-`Window` `cursor: Position` + `view_top`;
`switch_active_buffer` **zeroes both** (restore must run after
open). Reads exist (`pmacs.editor.cursor()` byte); **the only Lua
setter is line-based `move_to_line`** — no byte-offset setter, no
real `set_view_top`.
- **File I/O** (`src/file_io.rs`, complete): `save_atomic` (temp +
rename + fsync, mode-preserving), `load_file`, `current_meta`;
`FileMeta{mtime,size}` is the external-change detector.
- **Cadence**: `process.after-tick` hook (every frame — needs a
`monotonic_ms()` throttle) *or* `pmacs.async` + `pmacs.workers.sleep`
(the `fs.watch` pattern — cleaner for a fixed interval).
- **Session identity**: `pmacs.instance.identity()` → `{ instance_name,
working_directory }`. `--socket work` vs `--socket personal` give
distinct `instance_name`s; `instance_name` is `None` for the default
daemon / in-process (fall back to a `working_directory` hash).
- **Recentf / saveplace / desktop / autosave: confirmed absent.**
## Decisions
### Q#PS1 — Hybrid: four thin Rust primitives + Lua policy
Orchestration is Lua-friendly (enumerate via `pmacs.buffer.list`,
observe via `buffer.after-load` / `buffer.before-save` /
`editor.before-quit` hooks, cadence via `pmacs.async`, session key via
`pmacs.instance.identity`). But Lua can't do the load-bearing parts.
Add exactly four Rust surfaces; keep serialization + policy + cadence
in Lua builtin modules:
1. **`state_dir()` + `pmacs.state.{read,write,remove,path}`** — a
generic, **path-confined** key→file store under the generalized
state dir, atomic-write via `file_io::save_atomic` (avoids the
audit-flagged raw `io.open`). `remove` is needed by autosave
cleanup (Q#PS8).
2. **per-`BufferId` `file_path()`** getter (desktop-save enumerates
file buffers without switching to each).
3. **`pmacs.editor.goto_byte(pos)` + `set_view_top(n)`** — byte-exact
restore (saveplace/desktop), since switch zeroes both.
4. **`pmacs.session.save_desktop()` / `restore_desktop()`** — the
layout serde mirror + structural rebuild live in Rust (WindowIds
aren't restart-stable; the tree can't be rebuilt from the thin Lua
split API). This one primitive owns buffer-set + layout + cursor
serialization end-to-end.
### Q#PS2 — `state_dir()` generalization + `pmacs.state.*` (path-confined)
Factor `state_dir(xdg_state, home) -> Option<PathBuf>` returning
`.../pmacs`; rewrite `resolve_history_dir` as
`state_dir().map(|d| d.join("history"))`. **This is NOT a
behavior-preserving refactor at one edge** (an empty `XDG_STATE_HOME`):
the current `resolve_history_dir` treats `Some("")` as present and
returns a *relative* `pmacs/history` (writes into the cwd — a latent
bug). `state_dir()` **fixes this deliberately**: an empty/blank
`XDG_STATE_HOME` is treated as absent and falls through to
`~/.local/state/pmacs`. A test pins the new empty-XDG behavior; history
persistence keeps working (its own tests are the guard). Bet #3 is
scored on this being the *only* observable change to history.
`pmacs.state.{write(name,str), read(name)->str?, remove(name),
path(name)}` over `state_dir().join(name)`, atomic write +
`create_dir_all`. **Name confinement (High):** a name is a *relative*
key — reject absolute paths, empty names, any `.`/`..` component,
leading/trailing/`//` separators, and control chars; the only allowed
shape is one-or-more components of `[A-Za-z0-9._-]+` joined by `/`
(so `recentf`, `places`, `autosave/<hash>` pass; `../x`, `/etc/x`,
`a//b`, `` all reject). The resolved path is additionally asserted to
start with `state_dir()` (canonical-prefix belt). Without this,
`pmacs.state` would be the arbitrary-io primitive it exists to avoid.
Reads/writes are **no-ops when the state dir is unconfigured**
which is the case under `cfg(test)` (Q#PS9), so default-on builtins
never touch a developer's real `$XDG_STATE_HOME` in `cargo test`.
### Q#PS3 — Serialization: line-based text for Lua state; Rust serde for desktop
Decided up front so phase 1 doesn't discover a fifth primitive: **Lua
has no public JSON codec** (`lua_to_json` exists but only internally
for the MCP wire), and it doesn't need one. The Lua-owned state is
line-based text, the history-file shape `pmacs.state` already returns:
`recentf` is newline-delimited paths; `places` is one
`<cursor> <view_top> <path>` line per file (numbers first so the path —
which may contain spaces — is the whitespace-split remainder). The one
breaker, a newline inside a path, is pathological and named as
deferred. **desktop-save serializes in Rust** (`pmacs.session.*`, Q#PS5)
with `serde_json` internally — never crossing the Lua boundary — so no
Lua JSON primitive is added anywhere in the arc.
### Q#PS3b — saveplace (Lua, phase 1)
`builtin/runtime/saveplace.lua`: on `buffer.before-save` and on
`editor.before-quit`, record `path → {cursor, view_top}` into the
`places` state file (the line format above); on `buffer.after-load`,
look up the active buffer's path and `goto_byte` + `set_view_top`.
LRU-cap the map (~200 files). **On by default** with a disable knob
(Q#PS9).
### Q#PS4 — recentf (Lua, phase 1)
`builtin/runtime/recentf.lua`: a handler on **both `buffer.after-load`
(first open) and `buffer.after-switch` (re-visiting an already-open
file buffer)** moves the active buffer's path to the front of a deduped,
capped (~50) `recentf` state file — MRU, so re-visits refresh the
order, not just first loads. A `recent-files` command + binding opens
`minibuffer.read` over the list (the `editor.switch-buffer` shape) →
`find_or_open`. Recording is automatic; the picker is invoked on demand.
(The Arc 1b `listview` panel is an alternative surface if a browsable
list is wanted later.)
### Q#PS5 — desktop-save (Rust `pmacs.session.*`, phase 2)
`save_desktop()` serializes `{ session-key, layout mirror
(orientation/weights tree; leaf = {path, cursor byte, view_top}),
active_leaf: usize }` to `state_dir()/desktop/<session-key>`. **Only
file buffers** (`file_path().is_some()`) — scratch/`*special*` leaves
are dropped from the tree. **The active window is a leaf *preorder
index*, not a path** (Med/high): the same file can appear in multiple
leaves with different cursor/view state, so a path can't identify which
one had focus — the ordinal into the preorder leaf sequence can.
**Does NOT save buffer contents.** Like Emacs `desktop.el`, it saves
the *file list + positions*, not unsaved edits (that's autosave's job,
Q#PS8). A leaf whose buffer was modified at save time is recorded
**informationally only** — restore opens the on-disk file (clean) and
surfaces a one-line warning ("N buffers had unsaved changes when the
desktop was saved"); it never reconstructs dirty state. `restore_desktop()`
opens each file (`find_or_open`), rebuilds the split tree structurally,
sets each leaf's cursor/view_top, and focuses `active_leaf`.
**Opt-in** (auto-restore surprises): `pmacs.session.desktop_mode(true)`
in init.lua wires `editor.before-quit` → save and *arms* startup
restore (Q#PS7 — restore is triggered by the entry point, not inline).
### Q#PS6 — Session key (the daemon question)
Key on `pmacs.instance.identity()`. **Never use raw `instance_name` as
a filename** (even though `/` is mostly constrained today, other
separators/dots aren't) — use a stable *encoded* key: `name:<encoded>`
when `instance_name` is set (`--socket NAME`), else `cwd:<hash>` of
`working_directory` (Emacs's per-directory desktop model). The
encoding must itself satisfy the Q#PS2 name confinement (it becomes
the `desktop/<key>` subpath), so `<encoded>` is a hex/percent form or
a hash, never the raw string. So `--socket work` and `--socket
personal` restore different desktops; two plain `pmacs` sessions in
different project dirs likewise; the default daemon in one cwd shares
one desktop. Tests cover both the named-socket key and the
cwd-fallback key. No new identity is needed — the socket name already
threads to `InstanceIdentity`.
### Q#PS7 — Restore timing (restore is deferred, never inline in init)
The startup order is the trap: `EditorState::open(path)` calls
`EditorState::new()` first, and `new()` loads `init.lua` **before** the
file is opened (`src/editor.rs`). So `desktop_mode(true)` running inside
init cannot know a positional file arg is coming — if it restored
inline it would clobber (or race) the file the user asked for.
Therefore **`desktop_mode(true)` does not restore; it arms restore.**
It registers intent (a flag the core reads). The startup entry point —
after `new()`/`open()` has done its file-open routing — calls
`pmacs.session.restore_desktop()` exactly once, and **only when no
positional file arg was given** (a file arg means "open this," not
"restore my desktop" — Emacs's rule). Concretely: `main` threads a
`restore_desktop: bool` (true iff armed AND no file arg) into the
post-construction step that triggers the restore. Restore never runs
from init; init only sets the mode.
### Q#PS8 — autosave + crash recovery (Lua, phase 3)
A `pmacs.async` + `sleep(N s)` loop (the `fs.watch` pattern) writes a
recovery copy of each *modified* file buffer to
`state_dir()/autosave/<path-hash>` plus a sidecar recording the origin
path + the on-disk `FileMeta`. On `find_or_open`, if a recovery file
exists and the on-disk file's `FileMeta` matches the sidecar (the file
wasn't changed elsewhere), prompt to recover. A clean save/kill
**deletes the recovery file via `pmacs.state.remove`** (the Q#PS2
addition — no raw `pmacs.fs.remove(pmacs.state.path(...))`, which would
route around the confinement). Writes use `pmacs.state.write` under the
`autosave/` subpath; no new primitive beyond Q#PS2.
### Q#PS9 — Default-on policy + the disable knob + test inertness
saveplace + recentf: **on by default** (low-risk, quietly useful,
Emacs's `save-place-mode`/`recentf-mode` are commonly enabled).
desktop-save + autosave: **opt-in** via init.lua (auto-restore and
background writes are surprising to enable silently).
Two hard requirements on default-on:
- **A clear disable knob**: `pmacs.saveplace.enable(false)` /
`pmacs.recentf.enable(false)` (callable from init.lua) short-circuits
the hooks. The modules read an enabled flag at the top of each
handler.
- **No writes in `cargo test`.** The mechanism is already there: the
state dir is *configured once at startup* (like `history_dir`), and
that wiring is **skipped under `cfg(test)`** (the same guard that
skips init.lua loading). With no state dir configured,
`pmacs.state.write/read/remove` are no-ops (Q#PS2), so the default-on
saveplace/recentf hooks fire but touch no disk — the lib suite never
writes a developer's real `$XDG_STATE_HOME`. Acceptance tests that
*do* exercise persistence inject a tempdir state root explicitly (the
`load_user_config_at` precedent).
## Phasing
Three PRs; phase 1 delivers standalone value and is validated before
phase 2.
1. **State foundation + saveplace + recentf.** Rust: `state_dir()`
(+ empty-XDG fix + test) + `pmacs.state.{read,write,remove,path}`
(+ name-confinement rejection tests) + `goto_byte`/`set_view_top`.
Lua: `saveplace.lua` + `recentf.lua` + `recent-files`
command/binding + `enable(false)` knobs. Acceptance (tempdir state
root injected): state round-trip, confinement rejects `../x`
/ absolute / control chars, saveplace restores byte position after
reopen, recentf records + dedups + MRU-refreshes on re-visit + the
picker opens; and a check that with no state dir configured the
hooks write nothing.
2. **desktop-save.** Rust: per-buffer `file_path()` + layout serde
mirror + `pmacs.session.save_desktop/restore_desktop` + encoded
session-key. Lua: `desktop_mode` wiring (before-quit / arm-startup).
Acceptance: save→restore reconstructs a nested/asymmetric split
layout with correct buffers + cursors + `active_leaf` focus; the
same-file-in-two-leaves case; named-socket vs cwd-fallback key;
the no-file-arg restore gate; modified-at-save surfaces a warning
and restores clean (never dirty).
3. **autosave + crash recovery.** Lua: async-timer autosave +
recovery-on-open prompt; `FileMeta` external-change guard.
## Categorical bets (score at close)
1. **Four Rust primitives are enough** — the Lua policy fills in
saveplace/recentf/autosave and only desktop-save's layout needs
Rust. No fifth surface surfaces mid-arc.
2. **Structural layout rebuild is faithful** — reopening the serde
mirror reconstructs weighted split trees correctly (the untested
claim; nested/asymmetric splits are the risk).
3. **The `state_dir()` change touches history at exactly one edge**
the deliberate empty-`XDG_STATE_HOME` fix (Q#PS2) is the *only*
observable difference; normal history persistence is unchanged (its
tests + a new empty-XDG test are the guard). Not a pure refactor —
scored on nothing else shifting.
4. **On-by-default saveplace/recentf is unsurprising** — no
"why did my cursor jump" or "what's writing this file" reports.
## Deferred (named, not silently dropped)
- Saving unsaved buffer *content* in the desktop (autosave's job).
- Named/multiple desktops per session key (one desktop per key in v1).
- Restoring window-local overlays / minor state (buffers + positions
only).
- Remote/cross-machine desktop (paths are local).
- recentf as a browsable `listview` panel (minibuffer picker in v1).
- Per-project `init.lua` interaction with desktop scoping (post-v0.1,
per `docs/project.md`).
- saveplace for non-file buffers.
- Paths containing a literal newline (the line-based `places`/`recentf`
format breaks on them; pathological, unhandled in v1).

View File

@ -466,6 +466,8 @@ pub fn run_daemon(socket_path: PathBuf, instance_name: Option<String>) -> Result
// The editor outlives any single attachment; constructed once on
// the dispatcher thread and used until daemon shutdown.
let mut editor = EditorState::new();
// Real session: wire up on-disk persistence (history + pmacs.state).
editor.install_state_dirs();
// Mirror the daemon's `--socket NAME` and start time into the
// editor's `LocalInstanceInfo` so `pmacs.instance.identity()`
// (T M5.6f) reports the same identity the daemon hands back over

View File

@ -163,16 +163,14 @@ impl EditorState {
lua_host
.attach_editor(&core)
.expect("editor bindings + builtin chunks");
// Resolve the on-disk history directory before user config
// runs (so the user could in principle override it from
// `init.lua`). Skipped in test mode for the same reason as
// user config: don't touch the developer's real state dir.
#[cfg(not(test))]
{
if let Some(dir) = crate::minibuffer::user_history_dir() {
core.borrow_mut().minibuffer.history_dir = Some(dir);
}
}
// The on-disk state dirs (minibuffer history + pmacs.state) are
// deliberately NOT configured here — see `install_state_dirs`,
// called by the real entry points (`run` / `run_daemon`) only.
// Constructing an `EditorState` — which unit AND integration
// tests do directly — leaves them unconfigured, so default-on
// persistence (recentf/saveplace) writes nothing to a
// developer's real state dir during `cargo test`. Tests that
// exercise persistence inject a `StateDir` app-data explicitly.
// The async runtime: install pmacs._async raw helpers, then
// load the friendly Lua surface (`pmacs.async`, Handle class,
// `pmacs.workers.*`). Both must run before user config so a
@ -301,6 +299,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
@ -432,6 +446,24 @@ impl EditorState {
.eval(Some("@pmacs/runtime/async.lua:tick"), "pmacs._async.tick()");
}
/// Configure the on-disk state directories (minibuffer history +
/// `pmacs.state`) from the environment. The **real** entry points
/// (`run`, `run_daemon`) call this after construction; tests do not,
/// so neither the unit suite nor integration tests (which link the
/// lib without `cfg(test)`) touch a developer's real
/// `~/.local/state/pmacs`. Honors the `PMACS_STATE_HOME` override
/// (see [`crate::state::user_state_dir`]).
pub fn install_state_dirs(&self) {
if let Some(dir) = crate::minibuffer::user_history_dir() {
self.core.borrow_mut().minibuffer.history_dir = Some(dir);
}
if let Some(dir) = crate::state::user_state_dir() {
self.lua_host
.lua()
.set_app_data(crate::lua_bindings::StateDir(dir));
}
}
/// Construct an editor for a path. Empty buffer with `[new file]`
/// status if the path does not exist; loaded contents otherwise.
pub fn open(path: PathBuf) -> io::Result<Self> {
@ -1491,6 +1523,8 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
Some(path) => EditorState::open(path)?,
None => EditorState::new(),
};
// Real session: wire up on-disk persistence (history + pmacs.state).
state.install_state_dirs();
// Post-init dispatch: read whatever init.lua left in the
// RequestedAttach slot and decide whether to run local or hand
@ -4128,9 +4162,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

View File

@ -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 {

View File

@ -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;

View File

@ -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,46 @@ 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(())
})?,
)?;
}
{
// view_top(): the active window's first visible source line.
// The saveplace getter (Arc 3) — pairs with set_view_top so a
// reopen restores the viewport, not just the cursor.
let cc = core.clone();
editor.set(
"view_top",
lua.create_function(move |_, ()| {
i64::try_from(cc.borrow().view_top()).map_err(mlua::Error::external)
})?,
)?;
}
{
// 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(

View File

@ -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 {

365
src/state.rs Normal file
View File

@ -0,0 +1,365 @@
// 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> {
// `XDG_STATE_HOME` must be an absolute path per the XDG spec; a
// relative value would root state at a *cwd-relative* `pmacs/...`
// (the same latent bug the empty case had). Ignore relative values
// and fall through to `HOME`, which likewise must be absolute.
if let Some(xdg) = xdg_state.filter(|s| !is_blank(s)) {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return Some(p.join("pmacs"));
}
}
home.filter(|s| !is_blank(s)).and_then(|h| {
let p = PathBuf::from(h);
p.is_absolute()
.then(|| p.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))
{
// The override must also be absolute — a relative redirect would
// reintroduce the cwd-relative-state footgun.
let p = PathBuf::from(over);
if p.is_absolute() {
return Some(p.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 path under `base`, refusing any
/// route that could escape the state directory.
///
/// Two guards beyond [`validate_name`]'s lexical rules:
/// 1. a `starts_with(base)` belt (redundant with `validate_name`, kept
/// as defense in depth);
/// 2. **symlink confinement** — every *existing* component the key adds
/// under `base` is `lstat`'d, and a symlink (live *or* broken) is
/// rejected. Without this, a `base/autosave` symlink pointing at
/// `/tmp/out` would let `state.write("autosave/x", …)` write outside
/// `base` — the lexical check alone can't catch it. `base` itself may
/// be a symlink (a dotfile-managed `~/.local/state`); only the
/// components the *key* contributes are guarded.
///
/// # Errors
/// Propagates [`validate_name`], or errors on an escaping / symlinked key.
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");
}
let mut cur = base.to_path_buf();
for part in name.split('/') {
cur.push(part);
if let Ok(meta) = std::fs::symlink_metadata(&cur)
&& meta.file_type().is_symlink()
{
return Err("state key traverses a symlink");
}
}
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());
}
#[cfg(unix)]
#[test]
fn resolve_rejects_symlink_components() {
let root = std::env::temp_dir().join(format!("pmacs-symlink-{}", std::process::id()));
let base = root.join("pmacs");
let outside = root.join("outside");
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// A live symlink `base/evil -> outside` must be refused (else a
// write through it escapes the state dir).
let evil = base.join("evil");
std::os::unix::fs::symlink(&outside, &evil).unwrap();
assert!(resolve(&base, "evil/x").is_err(), "live symlink escape");
assert!(write(&base, "evil/x", b"nope").is_err());
assert!(!outside.join("x").exists(), "nothing was written outside");
// A broken symlink component is also refused (lstat sees it).
let broken = base.join("broken");
std::os::unix::fs::symlink(root.join("does-not-exist"), &broken).unwrap();
assert!(resolve(&base, "broken/y").is_err(), "broken symlink escape");
// A plain subdir is fine.
assert!(resolve(&base, "autosave/ok").is_ok());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn relative_xdg_and_home_are_ignored() {
// A relative XDG_STATE_HOME (spec violation) must not root state
// at a cwd-relative path; it falls through to HOME.
let d = state_dir(Some(OsStr::new("relstate")), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/home/u/.local/state/pmacs"));
// Relative XDG and relative HOME → None, never a relative root.
assert!(state_dir(Some(OsStr::new("rel")), Some(OsStr::new("relhome"))).is_none());
}
#[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());
}
}

View File

@ -0,0 +1,184 @@
//! 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::fmt::Write as _;
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() {
// A plain EditorState::new() must NOT configure a state dir — that
// is what keeps the whole integration-test suite (which links the
// lib without cfg(test)) from writing to a developer's real
// ~/.local/state/pmacs. Only the real entry points call
// install_state_dirs(); tests construct EditorState directly.
let s = EditorState::new();
assert!(
s.lua_host.lua().app_data_ref::<StateDir>().is_none(),
"new() must leave the state dir unconfigured"
);
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_and_view_top_on_reopen() {
let (s, dir) = editor_with_state_dir();
// A tall file so view_top can be non-zero.
let mut body = String::new();
for i in 0..40 {
let _ = writeln!(body, "line{i}");
}
let f = write_file(&dir, "place.rs", &body);
// Open, scroll to view_top 7, put the cursor at byte 48 (start of
// "line8", within that viewport), save (before-save records the
// place), then KILL — so the reopen is a fresh load
// (buffer.after-load), the cross-session path saveplace targets.
// "line0\n".."line9\n" are 6 bytes each → line8 begins at byte 48.
let (cursor, view_top): (i64, i64) = s
.lua_host
.lua()
.load(format!(
r#"
local b = pmacs.buffer.find_or_open({f:?})
pmacs.editor.set_view_top(7)
pmacs.editor.goto_byte(48)
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(), pmacs.editor.view_top()
"#
))
.eval()
.expect("place + save + kill + reopen");
assert_eq!(cursor, 48, "saveplace restored the cursor byte on reload");
assert_eq!(view_top, 7, "saveplace restored the viewport (view_top)");
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();
}