M8.1: filesystem worker primitives"
This commit is contained in:
parent
0b715de505
commit
3bbe5bf95d
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue