M8.1: filesystem worker primitives"

This commit is contained in:
Levi Neuwirth 2026-05-07 16:54:29 -04:00
parent 0b715de505
commit 3bbe5bf95d
3 changed files with 813 additions and 895 deletions

146
builtin/runtime/fs.lua Normal file
View File

@ -0,0 +1,146 @@
-- builtin/runtime/fs.lua --- T M8.1 filesystem primitives surface.
--
-- Wraps the raw `pmacs._async._dispatch_fs_*` primitives in
-- handle-returning APIs that match the rest of the worker surface
-- (pmacs.workers.sleep, pmacs.workers.compute_sum, etc.).
--
-- Public surface:
-- pmacs.fs.read_dir(path [, opts])
-- Returns a Handle. :await() yields a Lua array of entry tables:
-- { name=, kind=, size=, mtime=, mtime_nsec=, mode=, symlink_target= }
-- `kind` is one of "file" / "dir" / "symlink" / "other".
-- `symlink_target` is present only on symlink entries.
-- `opts` may contain `supersede = "<key>"` to chain into the M3
-- supersede semantics (a later read_dir under the same key
-- cancels the earlier one).
--
-- Order: entries are returned in *filesystem iteration order*,
-- which is whatever the kernel's `readdir` syscall returns. On
-- ext4 / btrfs this is hash-based, not name-sorted; on tmpfs it's
-- typically insertion order. dired-class packages (M8.2) own
-- user-facing sort modes (by name, mtime, size) per the spec ---
-- the fs primitive intentionally doesn't impose an order so the
-- package layer can pick one and not pay for sorting twice.
--
-- Path / name encoding: pmacs.fs requires UTF-8 names in v0.1. A
-- directory containing a non-UTF-8 entry produces a `failed`
-- status from :await() with a structured error naming the parent
-- and the offending raw bytes, rather than silently mangling the
-- name. Byte-preserving paths are post-v0.1 work.
--
-- Each fs primitive's worker observes its CancellationToken and
-- exits with a structured `{ tag = "cancelled" }` error from
-- :await() on cancel; callers either let it propagate (the typical
-- behavior under supersede) or pcall around it.
local async_mod = pmacs._async
assert(async_mod, "pmacs._async must be installed before pmacs.fs loads")
-- We need the Handle class from the async builtin. The `pmacs.async`
-- table exposes it via the new_handle helper indirectly; the simplest
-- shape is to build a thin local handle wrapper that mirrors the
-- async.lua one. To avoid duplicating the whole class, we go through
-- pmacs.workers.dispatch's existing handle factory.
--
-- In practice: pmacs.workers.* and pmacs.fs.* both produce handles
-- with the same surface (await/cancel/on_complete), but they're
-- created by different Lua code paths. The async.lua module exposes
-- a `_new_handle(id)` factory we reuse here; if it isn't present
-- (very early bootstrap), we synthesize a minimal handle.
local function build_handle(id)
if pmacs.workers and pmacs.workers._new_handle then
return pmacs.workers._new_handle(id)
end
-- Fallback: replicate the minimum the async.lua file exposes.
-- This branch fires only if pmacs.fs loads before pmacs.workers,
-- which the editor's load order doesn't permit; the assert at the
-- top of fs.lua guarantees pmacs._async exists, and async.lua
-- depends on the same primitive.
error("pmacs.fs: pmacs.workers._new_handle missing; did async.lua load before fs.lua?")
end
local fs = {}
-- Shared opts.supersede extractor; raises on misshapen opts.
local function supersede_key(opts, where)
if opts == nil then return nil end
if type(opts) ~= "table" then
error(where .. ": opts must be a table or nil, got " .. type(opts))
end
local k = opts.supersede
if k ~= nil and type(k) ~= "string" then
error(where .. ": opts.supersede must be a string")
end
return k
end
function fs.read_dir(path, opts)
if type(path) ~= "string" then
error("pmacs.fs.read_dir: path must be a string, got " .. type(path))
end
local id = async_mod._dispatch_fs_read_dir(path, supersede_key(opts, "pmacs.fs.read_dir"))
return build_handle(id)
end
function fs.stat(path, opts)
if type(path) ~= "string" then
error("pmacs.fs.stat: path must be a string, got " .. type(path))
end
local id = async_mod._dispatch_fs_stat(path, supersede_key(opts, "pmacs.fs.stat"))
return build_handle(id)
end
-- Mutating fs ops (rename / chmod / remove) intentionally do NOT
-- accept opts.supersede.
--
-- pmacs.fs.chmod follows symlinks: chmodding a symlink path
-- changes the *target's* mode, per chmod(2). This is asymmetric
-- with read_dir / stat (which use lstat and report the link's
-- own mode). dired/wdired authors should be aware: a chmod issued
-- on a symlink line and then a stat refresh shows the link's
-- (unchanged) mode --- the change took effect on the target. v0.1
-- doesn't expose lchmod-style "modify the link itself" because it
-- isn't portable across Unixes; it can land later if a real
-- package needs it.
--
-- The supersede semantics on read ops cancel an in-flight predecessor
-- so only the latest result reaches Lua --- safe because no disk
-- state has changed. For mutating ops, the underlying syscall has
-- a single observable instant (it either ran and changed disk, or
-- it didn't); cancelling "before" the syscall is a race the worker
-- can't reliably win. Exposing supersede here would be misleading:
-- a "cancelled" op might still have completed.
--
-- If a package needs at-most-one-pending semantics for mutations,
-- it should serialize on the package side (await each op before
-- dispatching the next). The fs primitive can't enforce that.
function fs.rename(from, to)
if type(from) ~= "string" then
error("pmacs.fs.rename: from must be a string, got " .. type(from))
end
if type(to) ~= "string" then
error("pmacs.fs.rename: to must be a string, got " .. type(to))
end
return build_handle(async_mod._dispatch_fs_rename(from, to))
end
function fs.chmod(path, mode)
if type(path) ~= "string" then
error("pmacs.fs.chmod: path must be a string, got " .. type(path))
end
if type(mode) ~= "number" or mode < 0 or mode > 0xfff then
error("pmacs.fs.chmod: mode must be a number in [0, 07777]")
end
return build_handle(async_mod._dispatch_fs_chmod(path, math.floor(mode)))
end
function fs.remove(path)
if type(path) ~= "string" then
error("pmacs.fs.remove: path must be a string, got " .. type(path))
end
return build_handle(async_mod._dispatch_fs_remove(path))
end
pmacs.fs = fs

View File

@ -1,895 +0,0 @@
-- builtin/runtime/repl.lua --- M6.4 REPL package skeleton.
--
-- A buffer with three regions (history / prompt / input) and an
-- intercept that enforces read-only / truncate-to-input policy.
-- Spec: §sec:repl-view.
--
-- # Region tracking: marks
--
-- The history/prompt boundaries are backed by core buffer marks.
-- `_history_end` and `_prompt_end` remain as compatibility mirrors
-- for tests and package introspection, but the authoritative positions
-- are `_history_end_mark` and `_prompt_end_mark`. This matters for
-- process prompts: user edits in the input region must not accidentally
-- move the prompt boundary, while package output inserted before the
-- prompt must move both boundaries with the rope.
--
-- # Self-write bypass
--
-- The intercept enforces region read-only. The package's own writes
-- (append_output, set_prompt, submit) need to land bytes inside the
-- read-only history/prompt regions. We toggle a `_self_write` flag
-- around package-driven calls; the intercept reads it and waives the
-- policy when set. The flag is reset under pcall to survive a rope
-- error mid-write.
--
-- # Submit does not append to history
--
-- handle:submit() pops the input region's text and returns it. It
-- does NOT append the popped text to history. The shell process is
-- in raw mode and echoes user input back; the parser appends the
-- echo to history. M6.5 wires submit's return value to
-- pmacs.process.write. Doing it twice (once on submit, once on
-- echo) would double the input and force M6.5 to detect-and-suppress
-- the echo, which is the messier path. M6.4 leaves this clean for
-- M6.5 to wire.
--
-- # Process integration (M6.5)
--
-- pmacs.repl.spawn { argv, env, cwd, rows, cols, name } extends create
-- by also spawning a child via the process supervisor in raw PTY mode
-- (per spec: shell-line-editor handles its own echo, which surfaces
-- through the parser to history). The handle gains a _proc_id field
-- and is registered on a per-frame pump driven by the
-- `process.after-tick` hook. The pump drains events_take(_proc_id)
-- and routes stdout/stderr through append_output and exit events
-- through _on_exit.
--
-- Stderr handling is defensive: in PTY mode the kernel TTY layer
-- merges stderr into stdout, so stderr events should not appear at
-- all. Routing them uniformly to append_output is a safety net
-- against future regressions or pipe-mode use; seeing one in PTY
-- mode signals an upstream bug.
--
-- # Scrollback management (M6.7)
--
-- History accumulates as bytes flow in from the process. Two retention
-- knobs (lines and bytes) bound the history region; truncation drops
-- complete command-output blocks from the oldest end whenever either
-- invariant is violated. Spec: §sec:repl-perf.
--
-- Block tracking: Handle:_blocks is an array of `{ start_byte, lines }`
-- entries. The first block (start_byte = 0) covers all bytes received
-- before the first submit; this pre-first-submit block is degenerate
-- but real, so a process that produces a long preamble before its
-- first prompt still has a valid truncation boundary. Each user
-- submission opens a new block (when the current block has bytes;
-- empty submissions don't create zero-length blocks, preserving the
-- strictly-increasing start_byte invariant).
--
-- Truncation runs at tick boundaries (process.after-tick), not on
-- each byte append: per-byte overhead would regress the M6.6
-- 100 MB/s ingest gate. A `_dirty_since_last_tick` flag is set in
-- _emit_history and cleared by the truncation pass; idle handles
-- skip the check entirely. Worst-case lag from "limit exceeded" to
-- "truncation runs" is one tick (~16 ms), which is below user-
-- visible thresholds.
--
-- Single-pass truncation: when both line and byte invariants can be
-- violated, removing oldest-block-at-a-time and rechecking after each
-- removal is order-independent and always terminates. The
-- alternative (satisfy lines-first, then bytes, or vice versa) gives
-- different results when blocks have wildly different sizes; the
-- single-pass loop is the deterministic shape.
pmacs.repl = {}
local repl = pmacs.repl
-- Scrollback retention knobs. Mutable at runtime; the truncation pass
-- reads them on every check, so changing these takes effect on the
-- next tick. Defaults track §sec:repl-perf: 10000 lines is the
-- navigation/search benchmark size, and 16 MiB is the byte-pressure
-- backstop sized to keep RSS well below the M6.6 200 MB ceiling
-- across multiple concurrent REPLs.
repl.config = {
scrollback_lines = 10000,
scrollback_bytes = 16 * 1024 * 1024,
}
-- { [raw_proc_id_int] = handle }. Keyed on raw integer because Lua
-- raw-table-key equality does not consult ProcessIdLua's __eq. The
-- after-tick hook walks this map; spawn inserts; close removes.
local proc_pump = {}
local Handle = {}
Handle.__index = Handle
-- Toggle the self-write bypass around a closure. pcall is used so
-- the flag resets even when the wrapped call errors, otherwise a
-- single failed write would leave the bypass on for every subsequent
-- user edit.
local function with_self_write(h, fn)
h._self_write = true
local ok, err = pcall(fn)
h._self_write = false
if not ok then error(err) end
end
local function new_handle(buffer_id)
return setmetatable({
_buf = buffer_id,
_parser = pmacs.ansi.parser(),
_history_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_prompt_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_history_end = 0,
_prompt_end = 0,
-- Latest SetStyle observed. M6.4 doesn't render this anywhere
-- (rendering will arrive with the M6.4-spec-style-channel work);
-- we capture it so the post-alt-screen-exit running style stays
-- consistent for M6.5.
_current_style = nil,
_alt_screen = false,
_title = nil,
_output_pos = 0,
_capture = "history",
_style_overlay = nil,
_self_write = false,
_intercept_handle = nil,
-- Scrollback block index (M6.7). The first block is degenerate
-- but real: it covers any bytes received before the first user
-- submit. Subsequent blocks open in Handle:submit. The active
-- (last) block is never removed by truncation. Each entry only
-- carries `start_byte`; line counts are computed lazily inside
-- the truncation pass, never on the per-byte _emit_history path
-- (gsub allocation per byte regressed M6.6's 100 MB/s ingest
-- gate by ~40%).
_blocks = { { start_byte = 0 } },
_dirty_since_last_tick = false,
}, Handle)
end
local function sync_marks(h)
h._history_end = h._history_end_mark:get()
h._prompt_end = h._prompt_end_mark:get()
end
local function history_end(h)
local pos = h._history_end_mark:get()
h._history_end = pos
return pos
end
local function prompt_end(h)
local pos = h._prompt_end_mark:get()
h._prompt_end = pos
return pos
end
local function set_history_end(h, pos)
h._history_end_mark:set(pos)
h._history_end = pos
end
local function set_prompt_end(h, pos)
h._prompt_end_mark:set(pos)
h._prompt_end = pos
end
-- ---------------------------------------------------------------------
-- Construction / teardown
-- ---------------------------------------------------------------------
function repl.create(opts)
opts = opts or {}
local name = opts.name or "*repl*"
local buf = pmacs.buffer.create(name)
local h = new_handle(buf)
h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op)
return repl._intercept(h, op)
end)
if pmacs.buffer.add_style_overlay then
h._style_overlay = pmacs.buffer.add_style_overlay(buf)
end
return h
end
-- Validate argv: must be a non-empty array of strings. Returns the
-- argv unchanged on success. Errors point at the corrected call shape
-- per the project's error-message-points-at-the-workaround posture.
local function validate_argv(argv)
if type(argv) ~= "table" then
error("pmacs.repl.spawn: opts.argv must be an array of strings " ..
"(e.g. argv = { \"bash\", \"-i\" })")
end
if #argv < 1 then
error("pmacs.repl.spawn: opts.argv must have at least one element " ..
"(the command); got an empty array")
end
for i, v in ipairs(argv) do
if type(v) ~= "string" then
error("pmacs.repl.spawn: opts.argv[" .. i .. "] must be a string; got "
.. type(v))
end
end
return argv
end
-- Last path component, or the whole string if there is no slash. Used
-- to derive the exit-marker name from argv[0]; users invoke `bash`,
-- not `/usr/bin/bash`. Empty input yields the empty string.
local function basename(s)
s = s or ""
-- Greedy `.*/` strips through the last slash, leaving the basename.
return (s:gsub("^.*/", ""))
end
local function copy_env(env)
local out = {}
if env then
for k, v in pairs(env) do out[k] = v end
end
return out
end
local function shell_prompt_marker_env(argv, base_env)
local shell = basename(argv[1])
if shell ~= "bash" and shell ~= "zsh" then
return base_env
end
local env = copy_env(base_env)
env.PS1 = "\27]133;A\7$ \27]133;B\7"
return env
end
function repl.spawn(opts)
opts = opts or {}
local argv = validate_argv(opts.argv)
local rows = opts.rows or 24
local cols = opts.cols or 80
local name = opts.name or ("*" .. basename(argv[1]) .. "*")
local h = repl.create { name = name }
h._argv = argv
h._display_name = basename(argv[1])
if h._display_name == "" then h._display_name = "process" end
-- Slice argv into command + args for the supervisor's spawn shape.
local args = {}
for i = 2, #argv do args[i - 1] = argv[i] end
local spec = {
label = name,
command = argv[1],
args = args,
pty = { rows = rows, cols = cols, mode = "raw" },
ansi = true,
}
if opts.cwd then spec.cwd = opts.cwd end
local env = opts.env
if opts.prompt_markers ~= false then
env = shell_prompt_marker_env(argv, env)
end
if env then spec.env = env end
local proc_id = pmacs.process.spawn(spec)
h._proc_id = proc_id
proc_pump[proc_id:raw()] = h
-- Make the REPL buffer the active window's current buffer. Without
-- this, the buffer-scoped RET / C-c / C-d bindings never fire (the
-- user's keys still target the previous active buffer) and the
-- buffer-lookup commands no-op. Mirrors the convention of
-- `pmacs.workers.show()` (see commands/default.lua:532).
if pmacs.window and pmacs.window.switch_buffer then
pcall(pmacs.window.switch_buffer, h._buf)
end
if pmacs.buffer.attach_style_overlay and h._style_overlay then
pcall(pmacs.buffer.attach_style_overlay, h._buf, h._style_overlay)
end
-- Buffer-scoped bindings. RET submits the input region to the
-- process; C-c sends SIGINT; C-d closes stdin (when input empty)
-- or deletes the character after cursor (otherwise). Each command
-- looks the handle up by buffer (linear scan over proc_pump; N is
-- typically 1-3, so list-walk dominates a hash-map allocation).
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "RET",
command = "pmacs.repl.submit-current",
}
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "C-c",
command = "pmacs.repl.send-sigint-current",
}
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "C-d",
command = "pmacs.repl.send-eof-current",
}
return h
end
-- Close: send SIGTERM and let the after-tick hook drive the rest of
-- the teardown via _on_exit (which removes the handle from
-- proc_pump, clears _proc_id, and calls pmacs.process.forget).
--
-- M6.9 audit shape: close() does NOT pre-empt _on_exit's cleanup.
-- Pre-M6.9 close() eagerly cleared _proc_id and proc_pump, which
-- meant the after-tick hook stopped routing events to the closed
-- handle and the supervisor's eventual exit event was never
-- observed by the package — the supervisor retained terminated
-- process records forever (a real leak across spawn-close cycles).
-- Post-M6.9: close() sets _closing so bound commands no-op
-- immediately; the handle stays registered until _on_exit observes
-- the exit and calls forget.
function Handle:close()
if self._intercept_handle then
pmacs.buffer.remove_intercept(self._intercept_handle)
self._intercept_handle = nil
end
if self._proc_id and not self._exited then
self._closing = true
-- Best-effort terminate. If the child already exited (events
-- drained the exit event since last tick), terminate raises;
-- pcall ignores that. _on_exit will fire on the next tick that
-- processes the exit event.
pcall(function() pmacs.process.terminate(self._proc_id) end)
end
end
-- ---------------------------------------------------------------------
-- Read-only queries
-- ---------------------------------------------------------------------
function Handle:buffer_id()
return self._buf
end
function Handle:history_end()
return history_end(self)
end
function Handle:prompt_end()
return prompt_end(self)
end
function Handle:title()
return self._title
end
function Handle:input_text()
return self._buf:slice(prompt_end(self), self._buf:len())
end
function Handle:alt_screen_active()
return self._alt_screen
end
function Handle:style_spans()
if not self._style_overlay then return {} end
return self._style_overlay:spans()
end
-- ---------------------------------------------------------------------
-- Package-driven writes
-- ---------------------------------------------------------------------
-- Feed raw bytes (synthetic in M6.4, PTY in M6.5) through the ANSI
-- parser; apply each event to the buffer. Text events land at
-- history_end (extending history and pushing prompt/input forward).
-- Style events update the running style. Alt-screen markers toggle
-- suppression at the parser level (so Text events between markers
-- never reach us).
function Handle:append_output(bytes)
self:append_events(self._parser:feed(bytes))
end
function Handle:append_events(events)
for _, ev in ipairs(events) do
local kind = ev.kind
if kind == "text" then
if self._capture == "prompt" then
self:_emit_prompt(ev.text)
else
self:_emit_history(ev.text)
end
elseif kind == "set_style" then
self._current_style = ev.style
elseif kind == "alt_screen_enter" then
self._alt_screen = true
elseif kind == "alt_screen_exit" then
self._alt_screen = false
elseif kind == "set_title" then
self._title = ev.title
elseif kind == "prompt_start" then
self:_begin_prompt_capture()
elseif kind == "prompt_end" then
self:_end_prompt_capture()
elseif kind == "command_start" or kind == "output_start" then
self:_begin_command_output()
elseif kind == "carriage_return" then
self._output_pos = self:_current_line_start()
elseif kind == "backspace" then
local line_start = self:_current_line_start()
if self._output_pos > line_start then
self._output_pos = self._output_pos - 1
end
elseif kind == "erase_to_eol" then
self:_delete_history_range(self._output_pos, self:_current_line_end())
elseif kind == "erase_line" then
local line_start = self:_current_line_start()
local line_end = self:_current_line_end()
self:_delete_history_range(line_start, line_end)
self._output_pos = line_start
-- bracketed_paste_* markers are delimiters only; process-emitted
-- contents are ordinary text events between them.
end
end
end
-- Replace the current prompt region's text. The history region is
-- untouched; the input region is preserved (it sits past prompt_end).
function Handle:set_prompt(text)
text = text or ""
local h_end = history_end(self)
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:replace(h_end, p_end, text)
end)
set_prompt_end(self, history_end(self) + #text)
sync_marks(self)
end
-- Pop the input region's text. Returns the popped string. Does NOT
-- append to history --- M6.5 echoes via the process round-trip.
--
-- M6.7: opens a new scrollback block at the current history boundary,
-- but only if the active block has accumulated bytes. Empty
-- submissions (submit-with-no-output-since-last-submit) leave the
-- block list unchanged, preserving the strictly-increasing
-- start_byte invariant.
function Handle:submit()
local text = self:input_text()
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:delete(p_end, self._buf:len())
end)
local last = self._blocks[#self._blocks]
local h_end = history_end(self)
if h_end > last.start_byte then
self._blocks[#self._blocks + 1] = { start_byte = h_end }
end
return text
end
-- ---------------------------------------------------------------------
-- Internal: history extension
-- ---------------------------------------------------------------------
function Handle:_emit_history(text)
if #text == 0 then return end
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local overwrite_len = math.min(#text, h_end - pos)
local insert_len = #text - overwrite_len
with_self_write(self, function()
if overwrite_len > 0 then
self._buf:replace(pos, pos + overwrite_len, text:sub(1, overwrite_len))
end
if insert_len > 0 then
self._buf:insert(pos + overwrite_len, text:sub(overwrite_len + 1))
end
end)
if insert_len > 0 then
self:_adjust_blocks_after_edit(pos + overwrite_len, 0, insert_len)
end
sync_marks(self)
set_history_end(self, h_end + insert_len)
if prompt_end(self) < history_end(self) then
set_prompt_end(self, history_end(self))
end
self._output_pos = pos + #text
self:_add_style_span(pos, pos + #text)
-- M6.7: mark the handle for the next tick's truncation check.
-- Per-byte work beyond this assignment regresses the M6.6 100 MB/s
-- ingest gate; line counting is deferred to _maybe_truncate.
self._dirty_since_last_tick = true
end
function Handle:_begin_prompt_capture()
self._capture = "prompt"
self:set_prompt("")
end
function Handle:_emit_prompt(text)
if #text == 0 then return end
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:insert(p_end, text)
end)
set_prompt_end(self, p_end + #text)
self:_add_style_span(p_end, p_end + #text)
end
function Handle:_end_prompt_capture()
self._capture = "history"
self._output_pos = history_end(self)
sync_marks(self)
end
function Handle:_begin_command_output()
self._capture = "history"
self:set_prompt("")
self._output_pos = history_end(self)
end
local function style_is_default(style)
if not style then return true end
return style.fg == "default"
and style.bg == "default"
and not style.bold
and not style.italic
and style.underline == "none"
and not style.reverse
end
function Handle:_add_style_span(start_pos, end_pos)
if not self._style_overlay then return end
if start_pos >= end_pos then return end
if style_is_default(self._current_style) then return end
self._style_overlay:add(start_pos, end_pos, self._current_style)
end
function Handle:_adjust_blocks_after_edit(start_pos, old_len, new_len)
local delta = new_len - old_len
if delta == 0 then return end
for i = 1, #self._blocks do
local b = self._blocks[i]
if b.start_byte > start_pos then
b.start_byte = b.start_byte + delta
if b.start_byte < start_pos then b.start_byte = start_pos end
end
end
end
function Handle:_current_line_start()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local prefix = self._buf:slice(0, pos)
local start = 0
local search = 1
while true do
local idx = prefix:find("\n", search, true)
if not idx then return start end
start = idx
search = idx + 1
end
end
function Handle:_current_line_end()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local suffix = self._buf:slice(pos, h_end)
local idx = suffix:find("\n", 1, true)
if idx then return pos + idx - 1 end
return h_end
end
function Handle:_delete_history_range(start_pos, end_pos)
if end_pos <= start_pos then return end
with_self_write(self, function()
self._buf:delete(start_pos, end_pos)
end)
local removed = end_pos - start_pos
sync_marks(self)
if self._output_pos > end_pos then
self._output_pos = self._output_pos - removed
elseif self._output_pos > start_pos then
self._output_pos = start_pos
end
self:_adjust_blocks_after_edit(start_pos, removed, 0)
self._dirty_since_last_tick = true
end
-- ---------------------------------------------------------------------
-- Scrollback truncation (M6.7)
-- ---------------------------------------------------------------------
-- Count newlines in `s` using string.find with the plain-text flag.
-- LuaJIT JIT-compiles this loop to memchr-equivalent speed, so it's
-- fast enough to call once per truncation pass on a multi-MB slice.
-- Not called on the hot _emit_history path.
local function count_newlines(s)
local n = 0
local i = 0
while true do
i = s:find("\n", i + 1, true)
if not i then return n end
n = n + 1
end
end
-- Lazy total-lines query. Computed by scanning the rope's history
-- region; allocates one Lua string of size _history_end. Cheap
-- enough at per-tick cadence for retention sizes in the spec range
-- (16 MiB / 10000 lines), and skipped entirely by the byte-only
-- shortcut in within_limits.
local function history_lines(h)
return count_newlines(h._buf:slice(0, history_end(h)))
end
-- Both invariants in one predicate, with a fast path that avoids the
-- expensive line count. Bound: every line is at least one byte
-- (the newline itself), so `_history_end <= scrollback_lines`
-- proves `lines <= scrollback_lines` without scanning. Same goes
-- for the byte invariant. Only when both quick checks fail do we
-- pay for the line scan.
local function within_limits(h)
local cfg = repl.config
local h_end = history_end(h)
if h_end > cfg.scrollback_bytes then return false end
if h_end <= cfg.scrollback_lines then return true end
return history_lines(h) <= cfg.scrollback_lines
end
-- Remove the oldest scrollback block. Adjusts every position-bearing
-- field by the removed length so positions stay consistent: history
-- and prompt boundaries shift down, surviving block start_bytes
-- shift down. The buffer:delete uses the self-write bypass so the
-- read-only-history intercept doesn't veto. Caller must guarantee
-- #_blocks >= 2.
local function drop_oldest_block(h)
local first = h._blocks[1]
local second = h._blocks[2]
local removed_bytes = second.start_byte - first.start_byte
with_self_write(h, function()
h._buf:delete(first.start_byte, second.start_byte)
end)
sync_marks(h)
h._output_pos = math.max(0, (h._output_pos or history_end(h)) - removed_bytes)
table.remove(h._blocks, 1)
for i = 1, #h._blocks do
h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes
end
end
-- Single-pass truncation. Removing oldest blocks one at a time,
-- rechecking both invariants after each removal, is order-
-- independent: the loop terminates when both hold or only the
-- active block remains. Splitting into "satisfy lines first, then
-- bytes" (or vice versa) gives different results when blocks have
-- wildly different sizes; the single-pass loop is the deterministic
-- shape and the one we want.
--
-- Fast path: with only the active block, there is nothing to drop
-- (the spec rule "removes complete command-output blocks" excludes
-- the in-progress one). Skipping the within_limits scan here keeps
-- the M6.6 stress test (no submits, one block forever) at zero
-- per-tick overhead.
function Handle:_maybe_truncate()
if #self._blocks <= 1 then return end
while not within_limits(self) and #self._blocks > 1 do
drop_oldest_block(self)
end
end
-- ---------------------------------------------------------------------
-- Intercept policy
-- ---------------------------------------------------------------------
-- Called for every apply_edit on the REPL's buffer. Returns nil
-- (pass-through), a transformed op table (truncate to input), or
-- raises (reject). Self-writes (the package's own append/set_prompt
-- /submit) bypass the policy via the _self_write flag.
function repl._intercept(h, op)
if h._self_write then
return nil
end
local prompt_end = prompt_end(h)
if op.kind == "insert" then
if op.pos < prompt_end then
error("REPL: history/prompt region is read-only (insert at "
.. op.pos .. "; input region begins at " .. prompt_end .. ")")
end
return nil
elseif op.kind == "delete" then
if op["end"] <= prompt_end then
error("REPL: history/prompt region is read-only (delete ["
.. op.start .. "," .. op["end"] .. "); input begins at "
.. prompt_end .. ")")
end
if op.start < prompt_end then
-- Truncate the range to the input region. Bytes are not
-- carried by Delete ops, so this is lossless.
return { kind = "delete", start = prompt_end, ["end"] = op["end"] }
end
return nil
elseif op.kind == "replace" then
if op["end"] <= prompt_end then
error("REPL: history/prompt region is read-only (replace ["
.. op.start .. "," .. op["end"] .. "); input begins at "
.. prompt_end .. ")")
end
if op.start < prompt_end then
-- Truncate the range; bytes pass through unchanged (per
-- LuaInterceptView's M6.4 byte-immutability rule). The user's
-- intended bytes still land at prompt_end onward; the
-- prompt-region portion of the original range is no longer
-- replaced. Spec: §sec:repl-view "edits that span the input
-- region boundary are truncated to the input region."
return { kind = "replace", start = prompt_end, ["end"] = op["end"] }
end
return nil
end
end
-- ---------------------------------------------------------------------
-- Per-frame event pump (T M6.5)
-- ---------------------------------------------------------------------
-- Drain a single handle's pending supervisor events, routing each to
-- the appropriate handle method. Stdout/stderr land in append_output
-- (which feeds the parser). Exit events flag the handle and clean up
-- the registry entry. "started" / "restarting" are informational and
-- ignored by M6.5.
local function drain_handle(h)
if not h._proc_id then return end
local events = pmacs.process.events_take(h._proc_id)
for _, ev in ipairs(events) do
local kind = ev.kind
if kind == "stdout" or kind == "stderr" then
-- Defensive: in PTY mode the kernel TTY layer merges stderr
-- into stdout, so stderr events should not appear here. If
-- they do (regression / pipe-mode use), routing them through
-- append_output preserves user output rather than dropping it.
h:append_output(ev.bytes)
elseif kind == "ansi" then
h:append_events(ev.events)
elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
h:_on_exit(ev)
end
end
end
-- Single subscription installed at module load. Walks the pump
-- registry and drains each handle. The hook fires every frame
-- (T M6.5 contract); an empty registry is a fast no-op.
--
-- M6.7: after draining (which may have appended bytes via
-- _emit_history → _dirty_since_last_tick = true), check truncation
-- on dirty handles. Per-tick is the right cadence: per-byte would
-- regress the M6.6 100 MB/s gate, and the worst-case lag of one
-- tick (~16 ms) is below user-visible thresholds.
pmacs.hook.add("process.after-tick", function()
for _, h in pairs(proc_pump) do
drain_handle(h)
if h._dirty_since_last_tick then
h._dirty_since_last_tick = false
h:_maybe_truncate()
end
end
end)
-- Format the exit marker emitted into history when the child
-- terminates. Uses basename(argv[0]) (stored as _display_name) so
-- /usr/bin/bash displays as `bash`, falling back to `process` for
-- empty argv[0]. Always leads with `\n` so that processes which
-- exited mid-line (no trailing newline) don't run on into the
-- marker. Symbolic signal names (SIGINT, SIGTERM, ...) rather than
-- numbers, since numbers vary by platform.
local function format_exit_marker(name, ev)
if ev.kind == "exited" then
return string.format("\n[%s exited with code %d]\n", name, ev.code or 0)
elseif ev.kind == "signaled" then
return string.format("\n[%s killed by %s]\n", name, ev.signal or "signal")
elseif ev.kind == "crashed" then
return string.format("\n[%s crashed: %s]\n", name, ev.error or "unknown")
else
return string.format("\n[%s exited]\n", name)
end
end
-- Emit the exit marker into history and finalize teardown.
-- Self-write bypass is required because the marker lands inside the
-- read-only history region. After this fires, bound commands no-op
-- (via the _exited check) and the supervisor no longer tracks the
-- process (forget releases its record).
--
-- M6.9 audit shape: _on_exit is the single point of teardown. It
-- removes the handle from proc_pump (so the after-tick hook stops
-- iterating it), clears _proc_id, and calls pmacs.process.forget so
-- the supervisor releases its terminated-process record. Pre-M6.9
-- close() eagerly cleared proc_pump and _proc_id, which prevented
-- _on_exit from firing and caused supervisor records to leak across
-- spawn-close cycles.
function Handle:_on_exit(ev)
if self._exited then return end
self._exited = true
local name = self._display_name or "process"
local marker = format_exit_marker(name, ev)
-- _emit_history wraps the buffer write in with_self_write so the
-- intercept lets the bytes through.
self:_emit_history(marker)
if self._proc_id then
proc_pump[self._proc_id:raw()] = nil
-- Forget releases the supervisor's record. pcall in case it has
-- already been forgotten by the user (e.g., manual cleanup).
pcall(pmacs.process.forget, self._proc_id)
self._proc_id = nil
end
end
-- ---------------------------------------------------------------------
-- Buffer-bound commands (T M6.5)
-- ---------------------------------------------------------------------
-- Find the spawned handle that owns `buf`. Linear scan over the pump
-- registry (N is typically 1-3 active REPLs). BufferIdLua's __eq
-- compares wrapped IDs, so two userdata wrapping the same buffer
-- compare equal here.
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in pairs(proc_pump) do
if h._buf == buf then return h end
end
return nil
end
-- Submit the input region to the process. After-tick later routes
-- the shell's echo (or the process's plain bytes-back, for cat-style
-- programs) into history via append_output. We append "\n" so the
-- recipient sees a complete line; raw-mode shells with line editors
-- treat that as the line-end signal.
pmacs.command.define {
name = "pmacs.repl.submit-current",
description = "Submit the REPL input region to the spawned process.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
local text = h:submit()
pmacs.process.write_stdin(h._proc_id, text .. "\n")
end,
}
-- C-c: deliver SIGINT to the foreground process group. Raw-mode
-- shells (which manage their own signal handling) typically catch
-- this, abort the in-progress line, and print a fresh prompt.
pmacs.command.define {
name = "pmacs.repl.send-sigint-current",
description = "Send SIGINT to the spawned REPL process.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
pmacs.process.signal(h._proc_id, "INT")
end,
}
-- C-d: spec-literal "close stdin on empty prompt", paired with
-- delete-char-forward when the input region is non-empty so users
-- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode
-- shells with a line editor interpret that as end-of-input. Non-empty
-- case deletes through the REPL buffer at the cursor when it is inside
-- the input region, falling back to the input start if the editor
-- cursor is stale/outside the region.
pmacs.command.define {
name = "pmacs.repl.send-eof-current",
description = "Close stdin on empty input region; delete-char-forward otherwise.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
if h:input_text() == "" then
pmacs.process.write_stdin(h._proc_id, "\x04")
else
local start = h:prompt_end()
local len = h._buf:len()
local pos = pmacs.editor.cursor()
if pos < start or pos >= len then pos = start end
if pos < len then h._buf:delete(pos, pos + 1) end
end
end,
}

667
src/fs.rs Normal file
View File

@ -0,0 +1,667 @@
// fs.rs --- Worker-dispatched filesystem primitives (T M8.1).
//! Filesystem operations exposed to packages as worker-dispatched
//! async APIs. The synchronous bodies live here; the
//! [`crate::async_runtime`] module owns dispatch / cancellation /
//! supersede plumbing and calls into [`read_dir_blocking`] (and the
//! single-file siblings landing in M8.1b) from the worker thread.
//!
//! ## Why a separate module
//!
//! The runtime's `dispatch_*` surface is the right place for the
//! cancellation-token poll cadence and the bus reply shape, but the
//! actual `lstat` / `readdir` / `readlink` calls have their own error
//! taxonomy and shape concerns (in particular: dired/wdired need
//! lstat-vs-target separation for symlinks, and stat callers want
//! the same per-entry shape minus the `name`). Keeping the bodies
//! out of `async_runtime.rs` lets that file stay focused on the
//! dispatch contract.
//!
//! ## Cancellation contract
//!
//! [`read_dir_blocking`] polls its [`CancellationToken`] every
//! [`READDIR_CANCEL_POLL_EVERY`] entries. A directory of 10K entries
//! observes cancel within a few hundred microseconds; the per-entry
//! overhead from polling is dominated by the underlying syscalls.
//! On cancel the function returns [`Err(FsError::Cancelled)`] and
//! the runtime translates that into the standard
//! [`crate::async_runtime::ReplyKind::Cancelled`] settled state.
use std::io;
use std::path::Path;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::worker::CancellationToken;
/// Cancel-poll cadence for [`read_dir_blocking`]. Picked so the
/// per-poll branch cost is negligible against the per-entry syscall
/// cost while keeping cancel latency under ~1ms even on huge
/// directories.
const READDIR_CANCEL_POLL_EVERY: usize = 32;
/// One directory entry as returned by [`read_dir_blocking`].
///
/// The shape is what `dired` / `magit-class` / `outline-class`
/// packages need without extra Lua-side parsing: `lstat`-style
/// metadata plus, for symlinks, the resolved target as a separate
/// field. Wdired's edit-to-rename layer treats edits to the `name`
/// column as `rename` calls; keeping `symlink_target` separate lets
/// package code display link destinations without confusing them for
/// editable basename bytes.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirEntry {
/// Basename of the entry (no directory prefix).
pub name: String,
/// What kind of filesystem object this is, per `lstat`. Symlinks
/// stay as [`FsEntryKind::Symlink`] regardless of what they
/// point to; callers that want the resolved kind follow
/// [`Self::symlink_target`] manually.
pub kind: FsEntryKind,
/// Size in bytes from `lstat`. For directories this is the
/// inode size, not the cumulative tree size.
pub size: u64,
/// Modification time as Unix seconds since epoch. `i64` so
/// pre-1970 timestamps (rare but legal) round-trip without
/// going negative through `u64`.
pub mtime_secs: i64,
/// Nanosecond component of the modification timestamp. Paired
/// with [`Self::mtime_secs`] so dired/wdired can detect same-size
/// rewrites that happen within one second.
pub mtime_nsec: u32,
/// Permission bits from `lstat`'s `st_mode`, masked to the low
/// 12 bits (setuid/setgid/sticky + rwx). Higher bits (file
/// type) are exposed via [`Self::kind`].
pub mode: u32,
/// For symlinks, the path the symlink resolves to (a literal
/// `readlink` result, not canonicalized). `None` for non-symlink
/// entries. Wdired's symlink-edit path consumes this directly.
pub symlink_target: Option<String>,
}
/// Discriminator for [`FsDirEntry::kind`].
///
/// `Other` covers device nodes, fifos, sockets, and anything else
/// the underlying `stat` reports. v0.1 packages have no need to
/// distinguish them; if a future package does, we extend the enum
/// and the Lua-side string discriminator with a new variant.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FsEntryKind {
/// Regular file.
File,
/// Directory.
Dir,
/// Symbolic link. [`FsDirEntry::symlink_target`] holds the
/// target.
Symlink,
/// Anything that's not a file/dir/symlink: device, fifo,
/// socket, etc.
Other,
}
impl FsEntryKind {
/// Stable string used at the Lua boundary. Lua callers compare
/// against literal strings rather than getting an integer
/// discriminator.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Dir => "dir",
Self::Symlink => "symlink",
Self::Other => "other",
}
}
}
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
///
/// `Io` is the catch-all for OS-level failures (path not found,
/// permission denied, etc.). `Cancelled` is the cooperative-cancel
/// path produced when the worker observes its [`CancellationToken`]
/// flipped mid-enumeration. `NonUtf8Path` is the explicit-rejection
/// path for entries whose name (or symlink target) isn't valid
/// UTF-8 --- the v0.1 fs surface doesn't expose byte-preserving
/// paths, and we'd rather fail loudly than silently mangle the
/// name through `to_string_lossy`.
#[derive(Debug, Error)]
pub enum FsError {
/// Underlying OS error; carries the offending path so error
/// messages can name what we tried.
#[error("filesystem operation on `{path}`: {source}")]
Io {
/// Path the operation was working on when the error
/// surfaced.
path: String,
/// The OS error.
#[source]
source: io::Error,
},
/// The cancel token was observed flipped before the operation
/// completed. No filesystem state has been mutated in this
/// case; the partial result is discarded.
#[error("filesystem operation cancelled")]
Cancelled,
/// An entry name or symlink target wasn't valid UTF-8. v0.1's
/// Lua fs surface uses Lua strings via `String`; non-UTF-8
/// bytes can't round-trip without a wider API. The error
/// names the parent directory (or the path being statted) and
/// the offending raw bytes so the user can see what they hit.
/// Byte-preserving paths are post-v0.1 work; if a real M8
/// package needs them, we widen the surface to accept and
/// return Lua strings (which are byte arrays at the C layer).
#[error(
"non-UTF-8 filesystem name in `{parent}`: raw bytes {bytes:?}; \
pmacs.fs v0.1 requires UTF-8 names. Rename the offending entry \
or open an issue if you need byte-preserving paths."
)]
NonUtf8Path {
/// Parent directory (for `read_dir`) or path being
/// statted (for `stat`'s symlink target case).
parent: String,
/// Raw bytes of the offending name, for diagnostic
/// display.
bytes: Vec<u8>,
},
}
/// Synchronous body of `read_dir`: enumerate `path` and return one
/// [`FsDirEntry`] per child. Polls `cancel` every
/// [`READDIR_CANCEL_POLL_EVERY`] entries; returns
/// [`FsError::Cancelled`] if the token is flipped mid-walk.
///
/// Each entry's `kind` and metadata come from `lstat` (not `stat`),
/// so symlinks are reported as symlinks regardless of what they
/// point to. For symlink entries we additionally call `readlink` to
/// fill in [`FsDirEntry::symlink_target`].
///
/// **Ordering:** the returned vec is in *filesystem iteration
/// order* (whatever `readdir(3)` produces). The primitive
/// intentionally doesn't sort: dired-class packages (M8.2) own
/// user-facing sort modes per the spec, and forcing a sort here
/// would either pick one arbitrarily or pay for sorting twice
/// when the package then sorts by its own criterion.
///
/// **UTF-8 constraint:** entry names and symlink targets must be
/// valid UTF-8. A non-UTF-8 entry surfaces as
/// [`FsError::NonUtf8Path`] naming the parent and the offending
/// bytes; we don't silently lossy-convert (the prior
/// `to_string_lossy` would have mangled dired/wdired round-trips).
///
/// Errors on the *parent* `read_dir` call surface as
/// [`FsError::Io`]. Errors on individual entries (a single broken
/// symlink, a permission-denied stat) currently propagate the same
/// way --- the cleanest behavior at this primitive layer is "fail
/// fast and let the caller decide whether a partial listing is
/// acceptable"; dired-class will likely want a per-entry-tolerant
/// wrapper but that's the package's job, not the primitive's.
pub fn read_dir_blocking(
path: &Path,
cancel: &CancellationToken,
) -> Result<Vec<FsDirEntry>, FsError> {
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})?;
let mut out: Vec<FsDirEntry> = Vec::new();
let parent_str = path.display().to_string();
for (i, entry_result) in iter.enumerate() {
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
let entry = entry_result.map_err(|source| FsError::Io {
path: parent_str.clone(),
source,
})?;
let entry_path = entry.path();
let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io {
path: entry_path.display().to_string(),
source,
})?;
let kind = classify(&metadata);
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?),
Err(source) => {
return Err(FsError::Io {
path: entry_path.display().to_string(),
source,
});
}
}
} else {
None
};
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
out.push(FsDirEntry {
name,
kind,
size: metadata.len(),
mtime_secs: mtime_to_unix_secs(&metadata),
mtime_nsec: mtime_to_unix_nsec(&metadata),
mode: mode_bits(&metadata),
symlink_target,
});
}
Ok(out)
}
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
/// [`FsError::NonUtf8Path`] (named with `parent_for_error` as the
/// directory the entry came from) when the name isn't valid UTF-8.
/// This is the M#3 fix for the M8.1 review: the prior
/// `to_string_lossy` silently mangled non-UTF-8 names, which broke
/// any dired/wdired round-trip that touched them.
fn path_to_utf8_string(name: &std::ffi::OsStr, parent_for_error: &str) -> Result<String, FsError> {
if let Some(s) = name.to_str() {
Ok(s.to_string())
} else {
Err(FsError::NonUtf8Path {
parent: parent_for_error.to_string(),
bytes: os_str_bytes(name),
})
}
}
/// Get the raw bytes of an `OsStr`. Unix-only --- pmacs ships only
/// on Unix in v0.1 and the M8 packages are designed for it. The
/// non-Unix branch returns the lossy form purely so the diagnostic
/// message has *something* to display; pmacs's audit forbids
/// shipping a non-Unix build that would actually take this path.
fn os_str_bytes(s: &std::ffi::OsStr) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(not(unix))]
{
s.to_string_lossy().into_owned().into_bytes()
}
}
/// Synchronous body of `stat`: returns metadata for a single path.
/// Like [`read_dir_blocking`], the metadata comes from `lstat`, not
/// `stat` --- a symlink is reported as a symlink with its target in
/// [`FsDirEntry::symlink_target`], not as the target's metadata.
/// `name` is the basename of `path`; callers already hold the full
/// path, but the basename keeps the result shape symmetric with
/// `read_dir`'s entries so packages can treat a stat result and a
/// `read_dir` entry interchangeably.
///
/// Cancel token is polled once at entry; the syscall itself is
/// non-cancellable but completes quickly enough that mid-syscall
/// cancellation isn't a meaningful concept here.
pub fn stat_blocking(path: &Path, cancel: &CancellationToken) -> Result<FsDirEntry, FsError> {
if cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
let path_str = path.display().to_string();
let metadata = std::fs::symlink_metadata(path).map_err(|source| FsError::Io {
path: path_str.clone(),
source,
})?;
let kind = classify(&metadata);
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(path) {
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &path_str)?),
Err(source) => {
return Err(FsError::Io {
path: path_str.clone(),
source,
});
}
}
} else {
None
};
let name = match path.file_name() {
Some(s) => path_to_utf8_string(s, &path_str)?,
None => String::new(),
};
Ok(FsDirEntry {
name,
kind,
size: metadata.len(),
mtime_secs: mtime_to_unix_secs(&metadata),
mtime_nsec: mtime_to_unix_nsec(&metadata),
mode: mode_bits(&metadata),
symlink_target,
})
}
/// Synchronous body of `rename`: atomic on-disk rename of `from`
/// to `to`. Cross-filesystem renames fall through to the OS
/// behavior (Linux returns EXDEV; the caller can either copy+remove
/// at the package layer or wait for a future `pmacs.fs.move`
/// primitive that handles the cross-fs case).
pub fn rename_blocking(from: &Path, to: &Path, cancel: &CancellationToken) -> Result<(), FsError> {
if cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
std::fs::rename(from, to).map_err(|source| FsError::Io {
path: format!("{} -> {}", from.display(), to.display()),
source,
})
}
/// Synchronous body of `chmod`: replace `path`'s permission bits
/// with the low 12 bits of `mode`. Higher bits (file type) are
/// silently ignored; callers shouldn't be passing them.
///
/// **Symlink semantics:** chmod on a symlink follows the link and
/// modifies the *target*, per the standard `chmod(2)` syscall.
/// This is asymmetric with [`read_dir_blocking`] /
/// [`stat_blocking`], which use `lstat` and report the link's own
/// metadata. Concrete consequence for dired/wdired: chmodding a
/// symlink line in the buffer changes the target's permission
/// bits, but a refresh of that line shows the link's own
/// (unchanged) mode --- on most filesystems a symlink reports
/// `0o777` regardless of what was done through it.
///
/// Packages that need lchmod-style behavior (modify the link
/// itself, not the target) need a platform-specific syscall not
/// portable across Unixes; v0.1's pmacs.fs surface picks the
/// portable `chmod(2)` shape and documents the asymmetry rather
/// than papering over it.
pub fn chmod_blocking(path: &Path, mode: u32, cancel: &CancellationToken) -> Result<(), FsError> {
if cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode & 0o7777);
std::fs::set_permissions(path, perms).map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})
}
#[cfg(not(unix))]
{
let _ = (path, mode);
Err(FsError::Io {
path: path.display().to_string(),
source: io::Error::new(io::ErrorKind::Unsupported, "chmod is Unix-only"),
})
}
}
/// Synchronous body of `remove`: delete a single filesystem object.
/// Inspects `lstat` metadata first to dispatch to `remove_file` or
/// `remove_dir` explicitly --- avoids depending on a specific errno
/// (Linux's `EISDIR` is 21; other Unix platforms can return
/// different values for "tried to unlink a directory"). Callers
/// that need to delete a non-empty directory walk it with
/// `read_dir` and remove children before the parent --- recursive
/// deletion is left to the package layer because the policy
/// ("confirm? skip on error? halt?") belongs there, not at the
/// primitive.
///
/// Symlinks are removed as symlinks: the `lstat` reports
/// [`FsEntryKind::Symlink`] regardless of the target's kind, and
/// `remove_file` on a symlink unlinks the link, not the target.
/// That matters for dired/wdired: a user deleting a symlink line
/// in the buffer should remove the link, not the file it points
/// at.
pub fn remove_blocking(path: &Path, cancel: &CancellationToken) -> Result<(), FsError> {
if cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
let metadata = std::fs::symlink_metadata(path).map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})?;
let result = if metadata.file_type().is_dir() {
// Real directory --- non-recursive remove. Symlinks-to-dirs
// are NOT directories per `lstat`, so they fall through to
// the unlink path below (which removes the link, not the
// target).
std::fs::remove_dir(path)
} else {
std::fs::remove_file(path)
};
result.map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})
}
fn classify(meta: &std::fs::Metadata) -> FsEntryKind {
let ft = meta.file_type();
if ft.is_symlink() {
FsEntryKind::Symlink
} else if ft.is_dir() {
FsEntryKind::Dir
} else if ft.is_file() {
FsEntryKind::File
} else {
FsEntryKind::Other
}
}
/// Extract permission bits from `Metadata`. Unix-only for now; on
/// Windows the high bits are zero and the low bits approximate the
/// `chmod`able subset. Pmacs only ships on Unix in v0.1, so the
/// `cfg(unix)` branch is the only one that matters.
fn mode_bits(meta: &std::fs::Metadata) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
{
let _ = meta;
0
}
}
fn mtime_to_unix_secs(meta: &std::fs::Metadata) -> i64 {
match meta.modified() {
Ok(time) => match time.duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
Err(e) => {
// Pre-epoch timestamp; signed seconds since epoch is
// negative.
let neg = e.duration().as_secs();
-i64::try_from(neg).unwrap_or(i64::MAX)
}
},
Err(_) => 0,
}
}
fn mtime_to_unix_nsec(meta: &std::fs::Metadata) -> u32 {
match meta.modified() {
Ok(time) => match time.duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => d.subsec_nanos(),
Err(e) => e.duration().subsec_nanos(),
},
Err(_) => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::worker::CancellationToken;
use std::os::unix::fs::symlink;
fn token() -> CancellationToken {
CancellationToken::new()
}
#[test]
fn read_dir_returns_entries_with_lstat_metadata() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
std::fs::create_dir(td.path().join("subdir")).expect("mkdir");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a.txt", "subdir"]);
let a = entries.iter().find(|e| e.name == "a.txt").unwrap();
assert_eq!(a.kind, FsEntryKind::File);
assert_eq!(a.size, 5);
let s = entries.iter().find(|e| e.name == "subdir").unwrap();
assert_eq!(s.kind, FsEntryKind::Dir);
}
#[test]
fn read_dir_reports_symlink_with_separate_target() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
symlink("real.txt", td.path().join("link")).expect("symlink");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let link = entries.iter().find(|e| e.name == "link").unwrap();
assert_eq!(link.kind, FsEntryKind::Symlink);
assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
let real = entries.iter().find(|e| e.name == "real.txt").unwrap();
assert_eq!(real.kind, FsEntryKind::File);
assert!(real.symlink_target.is_none());
}
#[test]
fn read_dir_polls_cancellation_token() {
let td = tempfile::tempdir().expect("tempdir");
// Populate with enough entries that the cancel-poll
// boundary is crossed before the walk completes.
for i in 0..(READDIR_CANCEL_POLL_EVERY * 4) {
std::fs::write(td.path().join(format!("f{i}")), b"").expect("write");
}
let cancel = token();
cancel.cancel();
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel");
assert!(matches!(err, FsError::Cancelled), "got {err:?}");
}
#[test]
fn stat_returns_metadata_for_a_single_path() {
let td = tempfile::tempdir().expect("tempdir");
let p = td.path().join("file.txt");
std::fs::write(&p, b"hello").expect("write");
let entry = stat_blocking(&p, &token()).expect("stat");
assert_eq!(entry.name, "file.txt");
assert_eq!(entry.kind, FsEntryKind::File);
assert_eq!(entry.size, 5);
assert!(entry.symlink_target.is_none());
}
#[test]
fn stat_on_symlink_reports_symlink_kind_and_target() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
symlink("real.txt", td.path().join("link")).expect("symlink");
let entry = stat_blocking(&td.path().join("link"), &token()).expect("stat");
assert_eq!(entry.kind, FsEntryKind::Symlink);
assert_eq!(entry.symlink_target.as_deref(), Some("real.txt"));
}
#[test]
fn rename_moves_a_file() {
let td = tempfile::tempdir().expect("tempdir");
let from = td.path().join("a.txt");
let to = td.path().join("b.txt");
std::fs::write(&from, b"x").expect("write");
rename_blocking(&from, &to, &token()).expect("rename");
assert!(!from.exists());
assert!(to.exists());
}
#[test]
fn chmod_changes_permissions() {
use std::os::unix::fs::PermissionsExt;
let td = tempfile::tempdir().expect("tempdir");
let p = td.path().join("f.txt");
std::fs::write(&p, b"x").expect("write");
chmod_blocking(&p, 0o600, &token()).expect("chmod");
let bits = std::fs::metadata(&p).expect("stat").permissions().mode() & 0o7777;
assert_eq!(bits, 0o600);
}
#[test]
fn remove_deletes_a_file() {
let td = tempfile::tempdir().expect("tempdir");
let p = td.path().join("f.txt");
std::fs::write(&p, b"x").expect("write");
remove_blocking(&p, &token()).expect("remove");
assert!(!p.exists());
}
#[test]
fn remove_falls_through_to_remove_dir_for_empty_directories() {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("empty");
std::fs::create_dir(&dir).expect("mkdir");
remove_blocking(&dir, &token()).expect("remove dir");
assert!(!dir.exists());
}
#[test]
fn remove_on_nonempty_directory_surfaces_io_error() {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("populated");
std::fs::create_dir(&dir).expect("mkdir");
std::fs::write(dir.join("child"), b"x").expect("write child");
let err = remove_blocking(&dir, &token()).expect_err("must fail");
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
}
#[test]
fn remove_of_symlink_leaves_target_in_place() {
let td = tempfile::tempdir().expect("tempdir");
let real = td.path().join("real.txt");
let link = td.path().join("link");
std::fs::write(&real, b"x").expect("write");
symlink("real.txt", &link).expect("symlink");
remove_blocking(&link, &token()).expect("remove link");
assert!(!link.exists(), "link must be gone");
assert!(real.exists(), "target must survive");
}
#[test]
fn read_dir_on_missing_path_reports_io_error() {
let td = tempfile::tempdir().expect("tempdir");
let missing = td.path().join("does-not-exist");
let err = read_dir_blocking(&missing, &token()).expect_err("must error");
match err {
FsError::Io { path, .. } => {
assert!(
path.contains("does-not-exist"),
"error must name the path: {path}"
);
}
FsError::Cancelled => panic!("expected Io, got Cancelled"),
FsError::NonUtf8Path { .. } => panic!("expected Io, got NonUtf8Path"),
}
}
#[test]
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
use std::os::unix::ffi::OsStrExt;
let td = tempfile::tempdir().expect("tempdir");
// 0xFF is invalid as a UTF-8 leading byte; this is a
// perfectly legal Unix filename but not representable as
// Rust `String`.
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
std::fs::write(td.path().join(bad_name), b"").expect("write entry");
let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8");
match err {
FsError::NonUtf8Path { parent, bytes } => {
assert!(
parent.contains(td.path().to_string_lossy().as_ref()),
"error must name the parent dir: {parent}"
);
assert!(
bytes.contains(&0xff),
"error must carry the offending raw bytes: {bytes:?}"
);
}
other => panic!("expected NonUtf8Path, got {other:?}"),
}
}
}