feat(dired): the directory view (Stage 1)

Dired is the file surface, not a rider on one: before Stage 0 (#162)
pmacs had no way to open a file by path, and browsing is the half a
user reaches for when they do not already know the path. Stage 1 ships
the view.

builtin/runtime/dired.lua: one buffer per directory named by the
canonical path (Q#DR2) with an ownership check before any paint (F7);
read-only intercept plus round-trip input (Q#DR3); a `dired` major mode
carrying mode-scoped keys (Q#DR8) -- RET/f visit, ^ parent, n/p, g
revert, q quit, s sort; cursor re-seated by basename across every
wholesale repaint (Q#DR9); file visits through
`pmacs.window.display_file` and directory descent through dired's own
window (Q#DR10); `C-x d` / `C-x C-j`; and `dired.kill-when-opening`
through the config registry.

Two Rust changes, both narrow:

* `read_dir` grows per-entry tolerance behind an opt (Q#DR6). Five
  per-entry conditions used to fail the entire listing, so a plain
  refresh of a busy directory could just fail; the module doc's claim
  that a tolerant wrapper was "the package's job" was false, because
  the primitive hands Lua one structured error and no partial vec.
  Per-entry readdir/lstat/readlink failures and non-UTF-8 symlink
  targets now land in an `errors` channel; parent-level failures and
  non-UTF-8 *names* stay fatal. The tolerance travels in the settled
  payload, so the Lua boundary keeps the bare-array shape the frozen
  M8.2 fixture consumes and never has to look the job back up. The read
  ops' opts parsing now rejects unknown keys, so a typo'd `tolerant`
  cannot silently degrade to the fatal contract.

* `normalize_buffer_path` is exposed as `pmacs.path.canonicalize`
  rather than mirrored in Lua. Q#DR2 named exposure the preferred end
  state; it needs no borrow plumbing, so dired's name-dedup and
  `display_file`'s `find_buffer_for_path` dedup cannot fork, and the
  mirror's Stage 2 removal is not owed.

tests/dired_acceptance.rs covers framing items 1-16 (22 tests), driven
through real key dispatch. Item 17 is the m8_1/m8_2/m8_3 gate.

One framing claim is corrected by the substrate: R2-3 expected a
dedicated dired panel to carry its dedication across a descent, but
`display_buffer` never replaces the buffer in a slot dedicated to
another one -- it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii). Dired does not try to unpin the
user's panel; both arms are pinned.
This commit is contained in:
Levi Neuwirth 2026-07-25 14:54:58 -04:00
parent 8c86d344c3
commit f71055a206
9 changed files with 2779 additions and 72 deletions

832
builtin/runtime/dired.lua Normal file
View File

@ -0,0 +1,832 @@
-- dired.lua --- the directory view (dired arc Stage 1).
--
-- Dired is not a convenience rider on an existing file surface: until
-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at
-- all, and browsing is the half a user reaches for when they do NOT
-- already know the path. So this is a primary surface, and the one
-- thing it may never do is refuse to render a listing --- hence the
-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust
-- this stage needed besides exposing the path normalizer.
--
-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view:
-- listing, navigation, sort, revert, quit. Marks and operations are
-- Stage 2; the editable wdired layer is Stage 3.
--
-- Public surface:
--
-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async
-- opts.display = "current" | "panel" (Q#BP11b, default "current")
-- opts.select_name = "<basename>" -- seat the cursor on it
--
-- M-x dired / C-x d -- prompt for a directory
-- M-x dired-jump / C-x C-j -- dired on this file's directory
--
-- In a dired buffer (mode-scoped keys, Q#DR8):
-- RET, f visit (directory -> descend, file -> display_file)
-- ^ parent directory
-- n / p move by line (<down> / <up> too)
-- g revert (re-read, preserving the cursor's entry)
-- q quit (restore the previous buffer, or window.quit in a panel)
-- s cycle sort mode (name -> mtime -> size)
--
-- Three structural decisions worth knowing before editing this file:
--
-- 1. ONE BUFFER PER DIRECTORY, named `*dired:<canonical path>*`
-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates
-- the current one. That is Emacs behavior, and it is also the only
-- way to keep the name honest --- there is no
-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint
-- leaves a buffer named after a directory it no longer shows.
--
-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it
-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself).
-- Dired's name-dedup and `display_file`'s `find_buffer_for_path`
-- dedup have to agree; two implementations that disagree on `//tmp`
-- or a `..` at root would mint two buffers for one directory with no
-- error anywhere.
--
-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched,
-- so each command spawns a coroutine and the work after the first
-- `:await()` resumes on a later tick --- outside interactive
-- dispatch. Two consequences: errors must be `pcall`ed and reported
-- here (an uncaught raise inside `pmacs.async` goes to *errors*, not
-- the status line), and `pmacs.window.*` calls made after the await
-- act for the *ambient* active frontend, since interactive origin
-- does not survive the tick boundary.
-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting
-- rather than a hardcoded policy: buffer-per-directory accumulates
-- buffers when walking a deep tree, and Emacs users differ on whether
-- that is a feature.
pmacs.config.define {
name = "dired.kill-when-opening",
description = "Kill the dired buffer being left when descending or ascending.",
type = "boolean",
default = false,
mutability = "live",
}
-- ---------------------------------------------------------------------------
-- Layout
-- ---------------------------------------------------------------------------
--
-- The mark column is column 0 (Q#DR4), so every other column sits two
-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it
-- blank: filling it in is Stage 2's job, but reserving it now means
-- Stage 2 does not have to move every column, and Stage 3's
-- column-classifying intercept can be written against constants that
-- did not shift under it. Offsets are computed from the widths for the
-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for
-- it in every wdired test.
local MARK_BYTES = 2
local KIND_BYTES = 1
local PERMS_BYTES = 9
local SIZE_BYTES = 10
local MTIME_BYTES = 16
local MARK_START = 0
local KIND_START = MARK_START + MARK_BYTES -- 2
local PERMS_START = KIND_START + KIND_BYTES -- 3
local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive)
local SIZE_START = PERMS_END + 1 -- 13
local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24
local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41
local BLANK_MARK = string.rep(" ", MARK_BYTES)
local SORT_MODES = { "name", "mtime", "size" }
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
--
-- handles: array of { buf, path, entries, errors, sort_mode, prev }.
--
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table
-- key: two BufferIdLua values for the same buffer are distinct
-- userdata, so a `handles[buf]` lookup would miss. The scan is over a
-- handful of dired buffers. Dead buffers are compacted out first, so a
-- command in a removed dired buffer sees "not in dired" rather than
-- operating on dead state (the M8.2 fixture's `find_handle` lesson).
local handles = {}
local function live_handles()
local live = {}
for _, h in ipairs(handles) do
local ok, valid = pcall(h.buf.is_valid, h.buf)
if ok and valid then live[#live + 1] = h end
end
handles = live
return live
end
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.buf == buf then return h end
end
return nil
end
local function handle_for_path(path)
for _, h in ipairs(live_handles()) do
if h.path == path then return h end
end
return nil
end
local function active_handle()
return handle_for_buffer(pmacs.window.buffer())
end
-- ---------------------------------------------------------------------------
-- Paths and names
-- ---------------------------------------------------------------------------
local canonicalize = pmacs.path.canonicalize
local function join_path(dir, name)
if dir:sub(-1) == "/" then return dir .. name end
return dir .. "/" .. name
end
-- Parent of a canonical directory, through the same normalizer: `..`
-- against the root folds away, so `/` is its own parent and no separate
-- root special case can drift out of agreement with the canonical form.
local function parent_path(path)
return canonicalize(join_path(path, ".."))
end
local function basename(path)
return path:match("([^/]+)/*$")
end
local function dirname(path)
local dir = path:match("^(.*)/[^/]*$")
if dir == nil then return nil end
if dir == "" then return "/" end
return dir
end
local function buffer_name(path)
return "*dired:" .. path .. "*"
end
local function buffer_named(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- The directory a prompt or a jump should start from: the active
-- buffer's own directory, else the process cwd (which the normalizer
-- yields for a bare "." because it absolutizes against it).
local function current_directory()
local buf = pmacs.window.buffer()
if buf ~= nil then
local ok, path = pcall(function() return buf:path() end)
if ok and path then
local dir = dirname(path)
if dir then return canonicalize(dir) end
end
local h = handle_for_buffer(buf)
if h then return h.path end
end
return canonicalize(".")
end
-- ---------------------------------------------------------------------------
-- Failure reporting
-- ---------------------------------------------------------------------------
-- `Handle:await()` raises structured tables (R45), so `tostring` on a
-- failure yields "table: 0x...". Every user-visible dired failure goes
-- through here.
local function failure_message(err)
if type(err) == "table" then
return tostring(err.message or err.tag or "error")
end
return tostring(err)
end
local function report(where, err)
pmacs.editor.set_status(where .. ": " .. failure_message(err))
end
-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- `rwxr-xr-x`, without the leading kind char (rendered separately so a
-- symlink shows `l` and a directory `d`). Arithmetic rather than bit
-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4.
local function fmt_perms(mode)
local function tri(bits)
local r = (bits >= 4) and "r" or "-"
local w = ((bits % 4) >= 2) and "w" or "-"
local x = ((bits % 2) >= 1) and "x" or "-"
return r .. w .. x
end
return tri(math.floor(mode / 64) % 8)
.. tri(math.floor(mode / 8) % 8)
.. tri(mode % 8)
end
local function kind_char(kind)
if kind == "dir" then return "d"
elseif kind == "symlink" then return "l"
elseif kind == "file" then return "-"
else return "?" -- device, fifo, socket
end
end
local function fmt_size(n)
return string.format("%" .. SIZE_BYTES .. "d", n)
end
local function fmt_mtime(secs)
-- Explicit format string, so the width is fixed and the result does
-- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s
-- behavior on a negative time is platform-dependent, so a
-- non-conforming result degrades to a fixed-width placeholder rather
-- than shifting every column right of it.
local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs)
if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then
return formatted
end
return string.rep("?", MTIME_BYTES)
end
-- POSIX permits any byte but `/` and NUL in a filename, including `\n`.
-- Rendering one verbatim would break the one-line-per-entry invariant
-- that cursor-line -> entry resolution rests on (and that Stage 3's
-- intercept will rest on harder), so control bytes are escaped. The
-- backslash goes first, which is what makes the encoding invertible ---
-- Stage 3 needs the exact inverse so a no-op commit cannot fire a
-- spurious rename. Carried over from the M8.2 fixture as decided
-- design, not re-litigated.
local function escape_displayable(s)
if s == nil then return "" end
s = s:gsub("\\", "\\\\")
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
-- NUL is deliberately absent from the class: the kernel forbids it in
-- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern
-- syntax) was covering a case that cannot occur.
s = s:gsub("[\1-\8\11\12\14-\31]", function(ch)
return string.format("\\x%02X", string.byte(ch))
end)
return s
end
local function render_entry(entry)
local target = ""
if entry.symlink_target then
target = " -> " .. escape_displayable(entry.symlink_target)
elseif entry.kind == "symlink" then
-- A tolerant listing keeps a symlink whose target could not be
-- represented (non-UTF-8) or read; say so rather than rendering a
-- bare `l` line that looks like a complete entry.
target = " -> ?"
end
return string.format(
"%s%s%s %s %s %s%s",
BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode),
fmt_size(entry.size), fmt_mtime(entry.mtime),
escape_displayable(entry.name), target)
end
-- Header (line 0) + one line per entry + the unreadable-count footer.
-- The footer exists because a tolerant listing that silently dropped
-- entries is worse than one that failed: the user has to know the view
-- is incomplete (and Stage 3's wdired refuses to open on one).
local function render_text(handle)
local lines = { handle.path .. ":" }
for _, entry in ipairs(handle.entries) do
lines[#lines + 1] = render_entry(entry)
end
local unreadable = #handle.errors
if unreadable > 0 then
lines[#lines + 1] = string.format("%d entries unreadable", unreadable)
end
return table.concat(lines, "\n")
end
-- Dired's own writes are the only ones that reach the buffer: the
-- read-only intercept rejects everything else, and this bypasses it.
local function paint(handle)
local text = render_text(handle)
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
end
-- ---------------------------------------------------------------------------
-- Cursor
-- ---------------------------------------------------------------------------
--
-- Entry i renders on line i (line 0 is the header), so the entry under
-- the cursor is `entries[cursor_line()]`.
local function entry_at_cursor(handle)
local line = pmacs.editor.cursor_line()
if line < 1 then return nil end
return handle.entries[line], line
end
local function index_of_name(handle, name)
if name == nil then return nil end
for i, entry in ipairs(handle.entries) do
if entry.name == name then return i end
end
return nil
end
-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving
-- line. Every repaint is wholesale, so without this a revert, a sort,
-- or any Stage 2 operation would drop the cursor to the header.
local function seat_cursor(handle, name, fallback_line)
local count = #handle.entries
if count == 0 then
pmacs.editor.move_to_line(0)
return
end
local target = index_of_name(handle, name)
if target == nil then
target = math.max(1, math.min(fallback_line or 1, count))
end
pmacs.editor.move_to_line(target)
end
-- ---------------------------------------------------------------------------
-- Sorting
-- ---------------------------------------------------------------------------
local function sort_entries(entries, mode)
if mode == "name" then
table.sort(entries, function(a, b) return a.name < b.name end)
elseif mode == "mtime" then
-- Newest first, name as a stable tiebreak so a directory of
-- same-second files renders deterministically.
table.sort(entries, function(a, b)
if a.mtime ~= b.mtime then return a.mtime > b.mtime end
return a.name < b.name
end)
elseif mode == "size" then
table.sort(entries, function(a, b)
if a.size ~= b.size then return a.size > b.size end
return a.name < b.name
end)
else
error("dired: unknown sort mode: " .. tostring(mode))
end
end
local function next_sort_mode(mode)
for i, candidate in ipairs(SORT_MODES) do
if candidate == mode then
return SORT_MODES[(i % #SORT_MODES) + 1]
end
end
return SORT_MODES[1]
end
-- ---------------------------------------------------------------------------
-- Reading
-- ---------------------------------------------------------------------------
-- Read and sort one directory without touching editor state, so a
-- failure happens before any side effect is committed (acceptance 15).
-- Must run inside `pmacs.async`.
--
-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not
-- fail because one child was unlinked between `readdir` and `lstat`.
-- Parent-level failures and non-UTF-8 *names* still raise.
local function read_listing(path, sort_mode)
local listing = pmacs.fs.read_dir(path, { tolerant = true }):await()
local entries = listing.entries
sort_entries(entries, sort_mode)
return entries, listing.errors
end
-- ---------------------------------------------------------------------------
-- Buffer ownership
-- ---------------------------------------------------------------------------
local READ_ONLY_LIMIT = 99
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign
-- buffer may already be called `*dired:/tmp*`. Painting into it through
-- `bypass_intercept` would clobber a user's data, so found-by-name is
-- NOT adoption: ownership means "this buffer is in dired's own handle
-- table" (F7).
--
-- That is deliberately narrower than the framing's "in the handle table
-- OR major_mode == dired": a foreign buffer that also carries the mode
-- is precisely the case the check exists to refuse, and a builtin's
-- handle table cannot be lost the way a reloadable package's can.
local function claim_handle(path)
local existing = handle_for_path(path)
if existing then return existing end
local name = buffer_name(path)
if buffer_named(name) then
local unique = nil
for i = 2, READ_ONLY_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if buffer_named(candidate) == nil then
unique = candidate
break
end
end
if unique == nil then
error(string.format("dired: %s is taken and no free variant remains", name))
end
name = unique
end
local buf = pmacs.buffer.create(name)
-- Read-only by the listview idiom (Q#DR3): every non-bypass edit is
-- rejected, and the intercept lives as long as the buffer.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
-- Q#DR3/Q#P6: while this buffer is active a semantic frontend must
-- round-trip keys, or optimistic apply would swallow the single-key
-- bindings (`g` would insert a `g` into a CRDT mirror instead of
-- reverting) and bypass the intercept entirely.
pmacs.buffer.set_round_trip_input(buf, true)
-- Q#DR8: the mode is what carries the keymap, and dired is #129's
-- first consumer of mode-scoped keys outside language detection.
pmacs.buffer.set_major_mode(buf, "dired")
local handle = {
buf = buf,
path = path,
entries = {},
errors = {},
sort_mode = SORT_MODES[1],
prev = nil,
}
handles[#handles + 1] = handle
return handle
end
-- ---------------------------------------------------------------------------
-- Display
-- ---------------------------------------------------------------------------
local function drop_handle(handle)
for i, candidate in ipairs(handles) do
if candidate == handle then
table.remove(handles, i)
return
end
end
end
-- Kill the dired buffer being left, when the user asked for it.
-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill`
-- redirects windows showing the doomed buffer, and doing that first
-- would fight the display we are about to perform.
local function kill_departed(departed, arriving)
if departed == nil or departed == arriving then return end
if not pmacs.config.get("dired.kill-when-opening") then return end
local ok, err = pcall(pmacs.buffer.kill, departed.buf)
if ok then
drop_handle(departed)
else
-- A buffer that could not be killed keeps its handle: dropping it
-- would leave a live dired buffer no command recognizes.
report("dired", err)
end
end
-- Where a dired buffer goes.
--
-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or
-- "current" is the raw switch every other adopter defaults to in
-- Stages 1-2, "panel" is the bottom side window.
--
-- Navigation (`departed ~= nil`) instead reuses the window dired
-- already occupies, which is the opposite routing from a file visit and
-- deliberately so (Q#DR10): the next directory is the same kind of
-- thing as the current one and belongs in the same slot, while a file
-- is not a dired buffer and belongs in the document area.
local function display(handle, opts, departed)
local side = nil
if departed ~= nil then
-- Dired's own window, not the request's: walking a tree in a side
-- window keeps the side window.
local params = pmacs.window.params()
side = params and params.side
elseif opts and opts.display == "panel" then
side = "bottom"
end
if side ~= nil then
-- A side slot DEDICATED to another buffer refuses the replacement
-- and this falls back to the document window (Q#BP3 2.iii). That is
-- both the substrate's documented policy and Emacs's, so dired does
-- not try to unpin the user's panel.
pmacs.window.display(handle.buf, { side = side, select = true })
else
pmacs.window.switch_buffer(handle.buf)
end
end
-- ---------------------------------------------------------------------------
-- Public: open a directory
-- ---------------------------------------------------------------------------
pmacs.dired = pmacs.dired or {}
local OPEN_OPTS = { display = true, select_name = true }
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
-- window it occupies when this is a navigation rather than a fresh
-- open. Awaits, so it must run inside `pmacs.async`; raises on a read
-- failure, having changed nothing. Returns the buffer.
local function open_directory(path, opts, departed)
if type(path) ~= "string" then
error("pmacs.dired.open: path must be a string, got " .. type(path))
end
opts = opts or {}
-- Validated up front, before the read and before any buffer exists,
-- so a bad opt leaves nothing to roll back (the
-- `parse_adopter_placement` discipline).
for key in pairs(opts) do
if not OPEN_OPTS[key] then
error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key)))
end
end
local wanted = opts.display
if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then
error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")',
tostring(wanted)))
end
local canonical = canonicalize(path)
-- Read first: a failure must leave no buffer, no window change, and
-- no handle behind.
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
local entries, errors = read_listing(canonical, sort_mode)
local handle = claim_handle(canonical)
handle.entries = entries
handle.errors = errors
handle.sort_mode = sort_mode
-- `q` returns to the buffer you came from, never to another dired
-- buffer (which would trap `q` walking back down the tree); on a
-- descent the arriving buffer inherits the departing one's origin.
if departed ~= nil then
handle.prev = departed.prev
else
local active = pmacs.window.buffer()
if active ~= nil and handle_for_buffer(active) == nil then
handle.prev = active
end
end
paint(handle)
display(handle, opts, departed)
-- Seating happens after the display: `switch_buffer` zeroes the
-- window cursor, so an earlier seat would be discarded.
seat_cursor(handle, opts.select_name, 1)
kill_departed(departed, handle)
return handle.buf
end
function pmacs.dired.open(path, opts)
return open_directory(path, opts, nil)
end
-- Every interactive entry point funnels through here: spawn the
-- coroutine the await needs, and turn a failure into a status message
-- rather than an uncaught raise inside `pmacs.async` (which would land
-- in *errors* and leave the user with a silent no-op).
local function open_async(path, opts, departed, where)
pmacs.async(function()
local ok, err = pcall(open_directory, path, opts, departed)
if not ok then report(where or "dired", err) end
end)
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
pmacs.command.define {
name = "dired",
description = "Open a directory listing (dired).",
fn = function()
local root = current_directory()
-- No completion source, deliberately. `source = "files"` would make
-- RET-on-empty open whatever sorts first (the minibuffer selects
-- candidate 0 whenever the list is non-empty, and a selected
-- candidate shadows typed text --- S0-1/S0-4), and RET-on-the-
-- default-directory is exactly the gesture `C-x d` exists for. The
-- field is prefilled instead, which is Emacs's own shape here.
pmacs.minibuffer.read {
prompt = "Dired: ",
initial = root,
history = "dired",
on_accept = function(value)
if value == nil or value == "" then return end
open_async(value, nil, nil, "dired")
end,
}
end,
}
pmacs.command.define {
name = "dired-jump",
description = "Open dired on the current file's directory, cursor on that file.",
fn = function()
local buf = pmacs.window.buffer()
local path = nil
if buf ~= nil then
local ok, value = pcall(function() return buf:path() end)
if ok then path = value end
end
if path == nil then
pmacs.editor.set_status("dired-jump: this buffer has no file")
return
end
local dir = dirname(path)
if dir == nil then
pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path)
return
end
open_async(dir, { select_name = basename(path) }, nil, "dired-jump")
end,
}
pmacs.command.define {
name = "dired.visit",
description = "Visit the entry under the cursor (descend a directory, open a file).",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry = entry_at_cursor(handle)
-- The header and the unreadable-count footer are not entries.
if entry == nil then return end
local target = join_path(handle.path, entry.name)
if entry.kind == "dir" then
open_async(target, nil, handle, "dired")
return
end
if entry.kind == "symlink" then
-- `read_dir`/`stat` are lstat-based, so the only way to learn
-- whether a link points at a directory is to try to list it. A
-- symlinked directory is an ordinary thing to walk into, and the
-- probe costs one syscall on symlink lines only.
pmacs.async(function()
local ok = pcall(function()
return pmacs.fs.read_dir(target, { tolerant = true }):await()
end)
if ok then
local descended, err = pcall(open_directory, target, nil, handle)
if not descended then report("dired", err) end
return
end
local visited, err = pcall(pmacs.window.display_file, target, { select = true })
if not visited then report("dired", err) end
end)
return
end
-- Q#DR10: `display_file`, never `find_or_open`, which switches the
-- active window in both branches before firing hooks --- in a
-- panel-displayed dired that would replace the panel with the
-- visited file, i.e. the panel swallows itself.
local ok, err = pcall(pmacs.window.display_file, target, { select = true })
if not ok then report("dired", err) end
end,
}
pmacs.command.define {
name = "dired.parent",
description = "Open the parent directory.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local parent = parent_path(handle.path)
if parent == handle.path then
pmacs.editor.set_status("dired: already at the filesystem root")
return
end
-- Seat on the directory we came from, the way Emacs's `^` does.
open_async(parent, { select_name = basename(handle.path) }, handle, "dired")
end,
}
pmacs.command.define {
name = "dired.revert",
description = "Re-read the directory, keeping the cursor on its entry.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
pmacs.async(function()
local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode)
if not ok then
-- On failure `entries` carries the raised value, not a listing.
report("dired", entries)
return
end
if not handle.buf:is_valid() then return end
handle.entries = entries
handle.errors = errors
paint(handle)
seat_cursor(handle, name, line)
end)
end,
}
pmacs.command.define {
name = "dired.sort-cycle",
description = "Cycle the sort mode: name -> mtime -> size.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
-- A pure reorder of the entries already in hand: sort is a display
-- decision, not a reason to re-read the directory.
handle.sort_mode = next_sort_mode(handle.sort_mode)
sort_entries(handle.entries, handle.sort_mode)
paint(handle)
seat_cursor(handle, name, line)
pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode)
end,
}
pmacs.command.define {
name = "dired.quit",
description = "Leave dired, restoring the previous buffer.",
fn = function()
local handle = active_handle()
if handle == nil then return end
-- Q#BP11b, matching `listview.quit`: `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when
-- dired really is in a side window.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = handle.prev
if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
end
pmacs.window.switch_buffer(target)
end,
}
-- ---------------------------------------------------------------------------
-- Keys
-- ---------------------------------------------------------------------------
-- Global: both sequences are unbound repo-wide, and both are the Emacs
-- defaults.
pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" }
pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" }
-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than
-- per buffer: a second dired buffer needs no `keymap.bind` of its own,
-- and Stage 3's wdired swap changes the whole keymap with the mode
-- instead of unbinding key by key.
local function bind(sequence, command)
pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command }
end
bind("RET", "dired.visit")
bind("f", "dired.visit")
bind("^", "dired.parent")
bind("n", "cursor.down")
bind("<down>", "cursor.down")
bind("p", "cursor.up")
bind("<up>", "cursor.up")
bind("g", "dired.revert")
bind("q", "dired.quit")
bind("s", "dired.sort-cycle")
-- ---------------------------------------------------------------------------
-- Test seam
-- ---------------------------------------------------------------------------
--
-- The layout constants, so acceptance can assert column positions
-- without hardcoding the numbers this file computes.
pmacs.dired._layout = {
MARK_START = MARK_START,
KIND_START = KIND_START,
PERMS_START = PERMS_START,
PERMS_END = PERMS_END,
SIZE_START = SIZE_START,
MTIME_START = MTIME_START,
NAME_START = NAME_START,
}

View File

@ -12,7 +12,10 @@
-- `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).
-- cancels the earlier one), and `tolerant = true` to swap the
-- all-or-nothing listing for `{ entries = ..., errors = ... }`
-- (see fs.read_dir's own comment below). Any other key is an
-- error rather than being silently ignored.
--
-- Order: entries are returned in *filesystem iteration order*,
-- which is whatever the kernel's `readdir` syscall returns. On
@ -69,24 +72,61 @@ end
local fs = {}
-- Shared opts.supersede extractor; raises on misshapen opts.
local function supersede_key(opts, where)
if opts == nil then return nil end
-- Shared read-op opts parser; raises on misshapen opts.
--
-- Unknown keys are REJECTED, not ignored. The earlier version read
-- `opts.supersede` and silently dropped everything else, which means a
-- typo'd `tolerant` would degrade to the fatal contract with no signal
-- at all --- exactly the failure the tolerant opt exists to prevent
-- (dired framing §8, minor c). `allowed` is the per-op whitelist.
local function read_opts(opts, where, allowed)
if opts == nil then return nil, false 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
for key in pairs(opts) do
if not allowed[key] then
local names = {}
for name in pairs(allowed) do names[#names + 1] = name end
table.sort(names)
error(string.format("%s: unknown opts key %q (expected one of: %s)",
where, tostring(key), table.concat(names, ", ")))
end
end
local key = opts.supersede
if key ~= nil and type(key) ~= "string" then
error(where .. ": opts.supersede must be a string")
end
return k
local tolerant = opts.tolerant
if tolerant ~= nil and type(tolerant) ~= "boolean" then
error(where .. ": opts.tolerant must be a boolean")
end
return key, tolerant == true
end
local READ_DIR_OPTS = { supersede = true, tolerant = true }
local STAT_OPTS = { supersede = true }
-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6):
--
-- read_dir(path) -> { <entry>, ... }
-- read_dir(path, { tolerant = true }) -> { entries = { <entry>, ... },
-- errors = { { name = ...?,
-- message = ... }, ... } }
--
-- The bare array is the M8.1 contract and stays exactly as it was, so
-- an existing consumer (the frozen M8.2 dired fixture consumes it with
-- `ipairs`) is unaffected. Under the opt, a per-entry `readdir` /
-- `lstat` / `readlink` failure and a non-UTF-8 symlink *target* become
-- `errors` rows instead of failing the whole listing; a failure on the
-- parent directory, and a non-UTF-8 entry *name*, stay fatal. An
-- `errors` row has no `name` when the entry never materialized.
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"))
local key, tolerant = read_opts(opts, "pmacs.fs.read_dir", READ_DIR_OPTS)
local id = async_mod._dispatch_fs_read_dir(path, key, tolerant)
return build_handle(id)
end
@ -94,7 +134,8 @@ 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"))
local key = read_opts(opts, "pmacs.fs.stat", STAT_OPTS)
local id = async_mod._dispatch_fs_stat(path, key)
return build_handle(id)
end

View File

@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel;
use serde::{Deserialize, Serialize};
use crate::fs::{
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking,
stat_blocking,
FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
remove_blocking, rename_blocking, stat_blocking,
};
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
@ -220,9 +220,10 @@ enum ReplyKind {
/// T M4.1.
Parse { duration_ms: u64 },
/// `dispatch_fs_read_dir` completed; payload is the directory
/// listing. The Vec is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1.
ReadDir(Vec<FsDirEntry>),
/// listing. The listing is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1; its
/// per-entry error channel is dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` completed; payload is the per-path
/// metadata. T M8.1.
Stat(FsDirEntry),
@ -266,10 +267,11 @@ pub enum JobResult {
duration_ms: u64,
},
/// `dispatch_fs_read_dir` produced a directory listing. The
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a
/// per-entry table when `_take_result` consumes the result.
/// T M8.1.
ReadDir(Vec<FsDirEntry>),
/// Lua boundary in [`crate::lua_bindings`] turns the entries into
/// per-entry tables when `_take_result` consumes the result, and
/// keys the result *shape* on whether the listing carries a
/// per-entry error channel. T M8.1 / dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` produced metadata for a single path. The
/// Lua boundary turns the [`FsDirEntry`] into the same table
/// shape `read_dir` entries use. T M8.1.
@ -832,11 +834,21 @@ impl AsyncRuntime {
/// `lstat`-style metadata. Polls cancel every batch of
/// entries; supersede follows the same rule as the other
/// dispatchers. T M8.1.
pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
///
/// `tolerance` selects the per-entry contract (dired Q#DR6):
/// [`ReadDirTolerance::Fatal`] is the original all-or-nothing
/// listing, [`ReadDirTolerance::PerEntry`] carries per-entry
/// failures alongside the entries that survived.
pub fn dispatch_fs_read_dir(
&self,
path: PathBuf,
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path);
let kind = run_fs_read_dir(&cancel, &path, tolerance);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
@ -1038,8 +1050,8 @@ impl AsyncRuntime {
ReplyKind::Parse { duration_ms } => {
PendingState::Complete(JobResult::Parse { duration_ms })
}
ReplyKind::ReadDir(entries) => {
PendingState::Complete(JobResult::ReadDir(entries))
ReplyKind::ReadDir(listing) => {
PendingState::Complete(JobResult::ReadDir(listing))
}
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
@ -1295,9 +1307,13 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
/// human-readable message attached.
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind {
match read_dir_blocking(path, cancel) {
Ok(entries) => ReplyKind::ReadDir(entries),
fn run_fs_read_dir(
cancel: &CancellationToken,
path: &Path,
tolerance: ReadDirTolerance,
) -> ReplyKind {
match read_dir_blocking(path, cancel, tolerance) {
Ok(listing) => ReplyKind::ReadDir(listing),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())

View File

@ -525,6 +525,17 @@ impl EditorState {
include_str!("../builtin/runtime/window.lua"),
)
.expect("load window builtin chunk");
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
// whose `window.panel-height` setting a `display = "panel"`
// listing resolves, and after the pre-runtime tables it drives
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
lua_host
.eval(
Some("@pmacs/builtin/runtime/dired.lua"),
include_str!("../builtin/runtime/dired.lua"),
)
.expect("load dired builtin chunk");
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and

View File

@ -4787,7 +4787,14 @@ fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
/// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
///
/// Public because dired needs the *same* canonical form the buffer
/// registry keys on (Q#DR2): its buffer-per-directory naming and
/// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror
/// of this function would be a second implementation of a canonical
/// form — the tab-width-constants class in miniature. `pmacs.path
/// .canonicalize` is this function, not a copy of it.
pub fn normalize_buffer_path(path: PathBuf) -> PathBuf {
let path = expand_tilde(path);
let abs = if path.is_absolute() {
path

304
src/fs.rs
View File

@ -116,6 +116,59 @@ impl FsEntryKind {
}
}
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
///
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
/// failed the *entire* listing, which makes a plain refresh of a busy
/// directory (`/tmp`, a build tree) fail outright. The module doc used
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
/// cannot be: the primitive hands Lua one structured error and no
/// partial vec, so there is nothing to be tolerant *with*.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadDirTolerance {
/// Any per-entry failure fails the whole listing. The original
/// M8.1 contract, and still the default at every Lua call site
/// that does not opt in.
Fatal,
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
/// enumeration continues. A failure on the *parent* `read_dir`
/// stays fatal (a directory you cannot open has no partial
/// answer), and so does a non-UTF-8 entry **name** --- see
/// [`FsError::NonUtf8Path`].
PerEntry,
}
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
///
/// `name` is optional because a per-entry `readdir` *iterator* error
/// has no filename to report: the entry never materialized, and the
/// underlying error is about the parent directory. Every other arm has
/// an entry in hand and names it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirEntryError {
/// Basename of the entry that failed, when one is known.
pub name: Option<String>,
/// Rendered failure, already formatted for display.
pub message: String,
}
/// What [`read_dir_blocking`] returns: the entries it could read, plus
/// the per-entry failures when the caller asked to tolerate them.
///
/// `errors` is `None` under [`ReadDirTolerance::Fatal`] and `Some`
/// (possibly empty) under [`ReadDirTolerance::PerEntry`]. The
/// distinction is load-bearing at the Lua boundary: it is what selects
/// the bare-array result shape the M8.1 surface promises from the
/// `{ entries = …, errors = … }` shape the tolerant opt returns, so the
/// conversion never has to look the job back up.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirListing {
/// One entry per readable child, in filesystem iteration order.
pub entries: Vec<FsDirEntry>,
/// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode.
pub errors: Option<Vec<FsDirEntryError>>,
}
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
///
@ -192,50 +245,97 @@ pub enum FsError {
/// `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.
/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
/// open has no partial answer.
///
/// Errors on individual entries (a permission-denied `lstat`, a child
/// unlinked between `readdir` and `lstat`, a `readlink` failure, a
/// non-UTF-8 symlink target) are governed by `tolerance`. Under
/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is
/// the M8.1 contract every existing caller relies on; under
/// [`ReadDirTolerance::PerEntry`] they land in
/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6).
///
/// A non-UTF-8 entry **name** is fatal in both modes. That is not a
/// listing problem but a path-representation one: [`FsDirEntry::name`]
/// is a `String` and every `pmacs.fs` op takes a `String` path, so a
/// tolerantly-rendered non-UTF-8 name would be a name the caller could
/// not pass back through `rename`. Byte-preserving paths are the named
/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target*
/// differs in kind --- the entry's own name is fine and nothing needs
/// to round-trip the target --- so it joins the per-entry channel.
pub fn read_dir_blocking(
path: &Path,
cancel: &CancellationToken,
) -> Result<Vec<FsDirEntry>, FsError> {
tolerance: ReadDirTolerance,
) -> Result<FsDirListing, 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 mut errors: Option<Vec<FsDirEntryError>> =
matches!(tolerance, ReadDirTolerance::PerEntry).then(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 = match entry_result {
Ok(entry) => entry,
Err(source) => {
// R2-2: the entry never materialized, so there is no
// name to report and the error names the parent.
record_entry_error(
&mut errors,
None,
FsError::Io {
path: parent_str.clone(),
source,
},
)?;
continue;
}
};
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 {
// Resolved first so a later per-entry failure can name it.
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let metadata = match std::fs::symlink_metadata(&entry_path) {
Ok(metadata) => metadata,
Err(source) => {
record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
});
}
},
)?;
continue;
}
} else {
None
};
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let kind = classify(&metadata);
let mut symlink_target = None;
if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
// A target we cannot represent leaves the entry in the
// listing with its target unknown, not the entry out of
// it: one weird symlink in `/tmp` used to take the
// whole directory down.
Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) {
Ok(target) => symlink_target = Some(target),
Err(error) => record_entry_error(&mut errors, Some(&name), error)?,
},
Err(source) => record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
},
)?,
}
}
out.push(FsDirEntry {
name,
kind,
@ -246,7 +346,33 @@ pub fn read_dir_blocking(
symlink_target,
});
}
Ok(out)
Ok(FsDirListing {
entries: out,
errors,
})
}
/// Route one per-entry failure: append it to the tolerant channel, or
/// propagate it when the caller asked for the fatal contract.
///
/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the
/// mode in the accumulator rather than passing it separately makes the
/// two impossible to disagree.
fn record_entry_error(
errors: &mut Option<Vec<FsDirEntryError>>,
name: Option<&str>,
error: FsError,
) -> Result<(), FsError> {
match errors {
Some(list) => {
list.push(FsDirEntryError {
name: name.map(ToOwned::to_owned),
message: error.to_string(),
});
Ok(())
}
None => Err(error),
}
}
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
@ -495,12 +621,23 @@ mod tests {
CancellationToken::new()
}
/// The fatal-mode shorthand every pre-Q#DR6 test used.
fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result<Vec<FsDirEntry>, FsError> {
read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| {
assert!(
listing.errors.is_none(),
"fatal mode must not open a per-entry channel"
);
listing.entries
})
}
#[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 entries = read_dir_fatal(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"]);
@ -516,7 +653,7 @@ mod tests {
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 entries = read_dir_fatal(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"));
@ -535,7 +672,7 @@ mod tests {
}
let cancel = token();
cancel.cancel();
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel");
let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel");
assert!(matches!(err, FsError::Cancelled), "got {err:?}");
}
@ -627,7 +764,7 @@ mod tests {
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");
let err = read_dir_fatal(&missing, &token()).expect_err("must error");
match err {
FsError::Io { path, .. } => {
assert!(
@ -640,6 +777,109 @@ mod tests {
}
}
#[test]
fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() {
// `Some(vec![])` rather than `None` is the whole shape
// contract: the Lua boundary keys the bare-array-vs-table
// result on `errors.is_some()`, so a clean tolerant listing
// must still carry the channel.
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir");
assert_eq!(listing.entries.len(), 1);
assert_eq!(listing.errors.as_deref(), Some(&[][..]));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
// A legal Unix symlink target that is not representable as a
// Rust `String`. Before Q#DR6 this single entry took the whole
// listing down.
symlink(
std::ffi::OsStr::from_bytes(b"tgt-\xff"),
td.path().join("weird"),
)
.expect("symlink");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir must survive a non-UTF-8 target");
let weird = listing
.entries
.iter()
.find(|e| e.name == "weird")
.expect("the entry itself must be listed");
assert_eq!(weird.kind, FsEntryKind::Symlink);
assert!(
weird.symlink_target.is_none(),
"an unrepresentable target reports as unknown"
);
assert!(
listing.entries.iter().any(|e| e.name == "real.txt"),
"the readable sibling must survive too"
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(errors[0].name.as_deref(), Some("weird"));
// The same directory under the fatal contract still fails
// whole-listing --- the opt is what changes behavior, not the
// walk.
let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail");
assert!(
matches!(err, FsError::NonUtf8Path { .. }),
"expected NonUtf8Path, got {err:?}"
);
}
#[test]
fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() {
use std::os::unix::fs::PermissionsExt;
// Failure mode 1 from the framing: a directory readable but not
// searchable. `readdir` yields the names; every child `lstat`
// fails with EACCES.
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("no-search");
std::fs::create_dir(&dir).expect("mkdir");
std::fs::write(dir.join("child"), b"x").expect("write child");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400");
let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok();
if searchable {
// Running as root (or on a filesystem that ignores the
// bits): the premise cannot be established, so assert
// nothing rather than pass vacuously.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
eprintln!("lstat still succeeds without search permission; skipping");
return;
}
let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry);
let fatal = read_dir_fatal(&dir, &token());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
let listing = tolerant.expect("tolerant read_dir must not fail the listing");
assert!(
listing.entries.is_empty(),
"the unreadable child cannot be described: {:?}",
listing.entries
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(
errors[0].name.as_deref(),
Some("child"),
"an lstat failure has an entry in hand and must name it"
);
let err = fatal.expect_err("fatal mode must still fail the whole listing");
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
@ -650,7 +890,7 @@ mod tests {
// 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");
let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8");
match err {
FsError::NonUtf8Path { parent, bytes } => {
assert!(

View File

@ -2439,6 +2439,7 @@ pub fn install(
)?;
pmacs.set("instance", install_instance_module(lua, registry)?)?;
pmacs.set("ansi", install_ansi_module(lua)?)?;
pmacs.set("path", install_path_module(lua)?)?;
pmacs.set("packages", install_packages_module(lua)?)?;
pmacs.set("state", install_state_module(lua)?)?;
pmacs.set("session", install_session_module(lua)?)?;
@ -3555,6 +3556,39 @@ impl UserData for AnsiParserLua {
}
}
/// Build the `pmacs.path.*` table: pure path arithmetic, no
/// filesystem access and no editor state.
///
/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`]
/// itself — the function the buffer registry's path keys already go
/// through on write and that `find_buffer_for_path` looks up with. It
/// expands a leading `~`, absolutizes against the process cwd, folds
/// `.` / `..` lexically, and drops redundant separators (so a trailing
/// slash disappears everywhere except at root). Symlinks are
/// deliberately **not** resolved: dired's `..` must return where the
/// user navigated from, and a not-yet-created "[new file]" path has
/// nothing to resolve.
///
/// Exposed rather than mirrored in Lua because dired keys one buffer per
/// directory on this form (Q#DR2). Two implementations that disagree on
/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape
/// root) would mint two buffers for one directory with no error
/// anywhere.
fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
let path = lua.create_table()?;
path.set(
"canonicalize",
lua.create_function(|_, raw: String| {
Ok(
crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw))
.to_string_lossy()
.into_owned(),
)
})?,
)?;
Ok(path)
}
/// Build the `pmacs.ansi.*` table. The only entry today is
/// `parser()`; future additions (e.g. an event-table-validator
/// helper) live alongside it.
@ -6479,6 +6513,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
Ok(t)
}
/// Convert a settled `read_dir` listing to its Lua result value.
///
/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode
/// listing carries no error channel and stays the **bare array** the
/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with
/// `ipairs` --- while a tolerant listing becomes
/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`.
/// Keying on the payload rather than on the job keeps the additive
/// promise checkable in one place.
fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result<mlua::Value> {
let entries = lua.create_table_with_capacity(listing.entries.len(), 0)?;
for (i, entry) in listing.entries.iter().enumerate() {
entries.set(i + 1, fs_dir_entry_to_lua(lua, entry)?)?;
}
let Some(errors) = listing.errors else {
return Ok(mlua::Value::Table(entries));
};
let rows = lua.create_table_with_capacity(errors.len(), 0)?;
for (i, error) in errors.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 2)?;
// `name` is absent for a per-entry `readdir` iterator error:
// the entry never materialized, so there is nothing to name.
if let Some(name) = &error.name {
row.set("name", name.as_str())?;
}
row.set("message", error.message.as_str())?;
rows.set(i + 1, row)?;
}
let out = lua.create_table_with_capacity(0, 2)?;
out.set("entries", entries)?;
out.set("errors", rows)?;
Ok(mlua::Value::Table(out))
}
fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result<mlua::Value> {
match payload {
StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))),
@ -6570,9 +6638,23 @@ pub fn install_async(
let rt = runtime.clone();
async_mod.set(
"_dispatch_fs_read_dir",
lua.create_function(move |_, (path, key): (String, Option<String>)| {
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref()))
})?,
lua.create_function(
move |_, (path, key, tolerant): (String, Option<String>, Option<bool>)| {
// dired Q#DR6: the tolerance is decided at dispatch
// and travels in the settled payload, so the result
// conversion below never has to look the job back up.
let tolerance = if tolerant == Some(true) {
crate::fs::ReadDirTolerance::PerEntry
} else {
crate::fs::ReadDirTolerance::Fatal
};
Ok(rt.dispatch_fs_read_dir(
std::path::PathBuf::from(path),
tolerance,
key.as_deref(),
))
},
)?,
)?;
}
@ -6777,16 +6859,15 @@ pub fn install_async(
i64::try_from(duration_ms).unwrap_or(i64::MAX),
));
}
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => {
Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
// Lua surface for fs.read_dir settle:
// status "ok", value = array of per-entry
// tables. T M8.1.
// tables (T M8.1), or the
// `{ entries = …, errors = … }` table when the
// caller opted into per-entry tolerance
// (dired Q#DR6).
out.push_back(mlua::Value::String(lua.create_string("ok")?));
let t = lua.create_table_with_capacity(entries.len(), 0)?;
for (i, entry) in entries.into_iter().enumerate() {
t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?;
}
out.push_back(mlua::Value::Table(t));
out.push_back(fs_dir_listing_to_lua(lua, listing)?);
}
Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
// Lua surface for fs.stat settle: status
@ -6933,9 +7014,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
"ok",
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::ReadDir(entries)) => (
JobOutcome::Complete(JobResult::ReadDir(listing)) => (
"ok",
mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)),
mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::Stat(entry)) => {
("ok", mlua::Value::String(lua.create_string(&entry.name)?))

View File

@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
format!("ok (parse {duration_ms}ms)")
}
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
format!("ok ({} entries)", entries.len())
JobOutcome::Complete(JobResult::ReadDir(listing)) => {
// Per-entry failures (dired Q#DR6) are counted here too: a
// tolerant listing that dropped half a directory is not the
// same observable outcome as a clean one.
match listing.errors.as_deref() {
Some([_, ..]) => format!(
"ok ({} entries, {} unreadable)",
listing.entries.len(),
listing.errors.as_ref().map_or(0, Vec::len)
),
_ => format!("ok ({} entries)", listing.entries.len()),
}
}
JobOutcome::Complete(JobResult::Stat(entry)) => {
format!("ok (stat {:?})", entry.name)

1469
tests/dired_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff