pmacs/tests/fixtures/pmacs-dired/init.lua

1385 lines
54 KiB
Lua

-- pmacs-dired/init.lua --- Directory view package (T M8.2).
--
-- The dired-class entry of M8's three universality-proof packages.
-- Validates the buffer-as-projection-of-external-state shape: each
-- line of the buffer corresponds to one filesystem entry, and edits
-- to lines (M8.3, wdired) become file operations.
--
-- This v0.1 covers the read-only directory view. Wdired's
-- editable-line layer lands in T M8.3.
--
-- Public surface:
--
-- local dired = require("pmacs-dired")
-- pmacs.async(function()
-- dired.open("/home/user")
-- end)
--
-- -- inside a dired buffer:
-- -- RET -> open subdirectory under cursor
-- -- <BS> -> navigate to parent directory
-- -- M-x pmacs-dired.sort-name sort by filename
-- -- M-x pmacs-dired.sort-mtime sort by modification time (newest first)
-- -- M-x pmacs-dired.sort-size sort by size (largest first)
--
-- Reload safety: this package is reload-safe under
-- `pmacs.packages.reload("pmacs-dired")`. The on-unload hook drops
-- per-buffer state and unregisters every command this package
-- defined, so re-running the chunk after reload doesn't hit
-- DuplicateName.
local M = {}
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
--
-- Each open dired buffer owns a handle: { buf, path, sort_mode, entries }.
-- We key handles by linear scan via `BufferIdLua.__eq` rather than
-- by buffer name (the name encodes the path, which navigation
-- mutates) or by raw id (BufferIdLua intentionally hides its inner
-- value via R22). The scan is over a small list --- typically 1-3
-- dired buffers per session --- so the cost is negligible.
--
-- `pmacs.buffer.remove(id)` can leave a window temporarily pointing
-- at a stale BufferId. `pmacs.window.buffer()` still returns that id,
-- and BufferId equality is raw-id equality, so a removed dired buffer
-- would otherwise still match its old handle. find_handle compacts
-- the table to live buffers before comparing; stale dired commands
-- then see "not in dired" instead of operating on dead buffer state.
local handles = {}
local function find_handle(buf)
local live = {}
local found = nil
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
if h.buf == buf then
found = h
end
end
end
handles = live
return found
end
local function active_handle()
return find_handle(pmacs.window.buffer())
end
-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- Format mode bits as `rwxr-xr-x` (9 chars, no leading kind char).
-- The kind char is rendered separately in render_entry so symlinks
-- show as `l` and dirs as `d` per dired convention.
--
-- Uses arithmetic rather than `&` / `>>` so the package compiles
-- under both LuaJIT (Lua 5.1, no integer bit ops) and Lua 5.4.
-- Mode is in the range 0..0o7777 (< 4096); `math.floor` + `%`
-- gets us a portable octal-digit extractor.
local function fmt_perms(mode)
local function tri(bits)
-- bits is 0..7. High bit = 4, middle = 2, low = 1.
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
local owner = math.floor(mode / 64) % 8
local group = math.floor(mode / 8) % 8
local other = mode % 8
return tri(owner) .. tri(group) .. tri(other)
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, etc.
end
end
local function fmt_size(n)
-- Right-aligned to 10 columns. dired-class doesn't bother with
-- human-readable units in v0.1; the package layer can format
-- differently if a user surfaces the need.
return string.format("%10d", n)
end
local function fmt_mtime(secs)
-- ISO-8601-ish, minute precision. Stable across locales because
-- os.date with an explicit format string ignores LC_TIME.
return os.date("%Y-%m-%d %H:%M", secs)
end
-- Escape control characters that would otherwise break the
-- one-line-per-entry invariant or make the listing hard to inspect,
-- and the literal backslash that would otherwise alias with our
-- escape sequences. Unix filenames can contain `\n` and `\r`
-- (POSIX permits any byte except `/` and NUL); rendering them
-- verbatim produces a multi-line buffer entry, which would break
-- cursor-line -> entry resolution and the wdired layer's line-edit
-- detection.
--
-- The wdired layer (T M8.3) parses rendered entry text to detect
-- user edits, so the escape must be unambiguous --- a filename
-- containing the literal two characters `\` + `n` must not look
-- the same as one containing an actual newline. We therefore
-- escape `\` itself first (so a literal `\n` renders as `\\n`,
-- distinct from an actual newline's `\n`).
--
-- Other C0 controls render as `\xNN`; printable bytes, DEL, and
-- UTF-8 multibyte sequences pass through. The encoding mirrors
-- `ls --quoting=c`'s handling for the subset of characters dired
-- needs.
local function escape_displayable(s)
if s == nil then return "" end
-- Order matters: backslash must be escaped first so subsequent
-- substitutions only insert the literal-backslash escapes once.
s = s:gsub("\\", "\\\\")
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
s = s:gsub("[%z\1-\8\11\12\14-\31]", function(ch)
return string.format("\\x%02X", string.byte(ch))
end)
return s
end
-- Inverse of escape_displayable: turn a rendered (escaped) basename
-- back into the bytes the kernel sees. Returns (real, nil) on
-- success or (nil, error) on a malformed escape. Recognized
-- sequences mirror escape_displayable exactly --- `\\`, `\n`, `\r`,
-- `\t`, and `\xNN` (case-insensitive hex). Any other backslash
-- escape is rejected: the renderer never produces it, so seeing
-- one means the user typed something ambiguous, and we'd rather
-- fail at commit than silently rename to whatever bytes our
-- best-effort decoder produced.
--
-- Why this exists at the M8.3 layer (not in the renderer): commit
-- compares the user's edited line against the snapshot's *real*
-- name. Without an inverse, a file whose real name is "weird\nname"
-- renders as `weird\nname` and a no-op commit would parse the
-- rendered text as a NEW name `weird\nname` (literal backslash + n)
-- and fire a spurious rename. unescape_displayable fixes that.
local function unescape_displayable(s)
local out = {}
local i = 1
local n = #s
while i <= n do
local b = s:byte(i)
if b ~= 92 then -- not '\\'
out[#out + 1] = string.char(b)
i = i + 1
else
if i == n then
return nil, "trailing backslash with no escape character"
end
local nxt = s:sub(i + 1, i + 1)
if nxt == "\\" then
out[#out + 1] = "\\"; i = i + 2
elseif nxt == "n" then
out[#out + 1] = "\n"; i = i + 2
elseif nxt == "r" then
out[#out + 1] = "\r"; i = i + 2
elseif nxt == "t" then
out[#out + 1] = "\t"; i = i + 2
elseif nxt == "x" then
if i + 3 > n then
return nil, ("incomplete \\xNN escape at byte " .. i)
end
local hex = s:sub(i + 2, i + 3)
if not hex:match("^%x%x$") then
return nil, ("invalid \\xNN escape '\\x" .. hex ..
"' at byte " .. i)
end
out[#out + 1] = string.char(tonumber(hex, 16))
i = i + 4
else
return nil, ("unknown escape '\\" .. nxt ..
"' at byte " .. i ..
" (only \\\\, \\n, \\r, \\t, \\xNN are valid)")
end
end
end
return table.concat(out), nil
end
-- Render one entry as a single line. Symlinks append ` -> target`
-- so the user can see what each link points at; the suffix is
-- purely informational. v0.1 wdired does not support editing the
-- target (commit rejects any change to the suffix); a future
-- milestone could add a target-edit path with a separate
-- `symlink` reconfiguration syscall.
local function render_entry(e)
local kch = kind_char(e.kind)
local perms = fmt_perms(e.mode)
local name = escape_displayable(e.name)
local target = ""
if e.symlink_target then
target = " -> " .. escape_displayable(e.symlink_target)
end
return string.format(
"%s%s %s %s %s%s",
kch, perms, fmt_size(e.size), fmt_mtime(e.mtime),
name, target
)
end
-- ---------------------------------------------------------------------------
-- Sort modes
-- ---------------------------------------------------------------------------
local SORT_MODES = { "name", "mtime", "size" }
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; stable tiebreak by name so renders are
-- deterministic when two entries share an mtime (common on
-- fresh extracts).
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
-- Largest first; stable tiebreak by name.
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("pmacs-dired: unknown sort mode: " .. tostring(mode))
end
end
-- ---------------------------------------------------------------------------
-- Buffer rendering
-- ---------------------------------------------------------------------------
-- Build the full buffer text: header line (current path + ":")
-- followed by one entry per line. Returns the rendered string.
local function render_text(handle)
local lines = { handle.path .. ":" }
for _, e in ipairs(handle.entries) do
lines[#lines + 1] = render_entry(e)
end
return table.concat(lines, "\n")
end
-- Replace the buffer's current contents with the rendered text.
-- Used by every operation that changes the displayed listing
-- (initial open, navigate, sort). The `painting` flag is set so a
-- wdired intercept (M8.3) can let the package's own writes pass
-- through without re-validating each as a user edit.
--
-- The full buffer-touching operation runs inside a pcall'd closure
-- so the flag is guaranteed to clear before any error propagates.
-- This includes `handle.buf:len()` --- the closure form is
-- specifically necessary because Lua evaluates pcall's argument
-- list eagerly: `pcall(fn, a, b, buf:len())` evaluates `buf:len()`
-- *before* entering the protected scope, so a `:len()` failure
-- (e.g., on a buffer that was removed via pmacs.buffer.remove
-- between paint() entries) would skip past the flag-clear and
-- leave the wdired intercept in passthrough mode for subsequent
-- user edits --- silently disabling validation. M8.4 audit
-- finding 2 (same shape M6.9 finding 7 called out for the REPL
-- package's `_self_write` flag).
local function paint(handle)
local text = render_text(handle)
handle.painting = true
local ok, err = pcall(function()
handle.buf:replace(0, handle.buf:len(), text)
end)
handle.painting = false
if not ok then error(err) end
end
-- ---------------------------------------------------------------------------
-- Reading the directory
-- ---------------------------------------------------------------------------
-- Read and sort a directory without mutating editor state. Both
-- initial open and later navigation use this so read failures happen
-- before any side effects are committed.
local function read_entries(path, sort_mode)
local entries = pmacs.fs.read_dir(path):await()
sort_entries(entries, sort_mode)
return entries
end
-- Navigate `handle` to `target_path`: read the directory first,
-- then atomically commit `path`, `entries`, and the buffer paint.
-- If `read_dir(target_path):await()` fails, the handle and buffer
-- remain pointing at the previous location --- which is the
-- correct dired-class behavior for a navigation that couldn't
-- complete. (The earlier shape mutated `handle.path` first and
-- left the buffer showing the old listing under a header for the
-- new path on read failure, breaking the line-per-entry contract
-- and confusing a subsequent sort.)
--
-- Errors propagate from :await() as the standard
-- `{ tag = "failed", message = ... }` structured value, which the
-- caller can pcall around. Must run inside `pmacs.async(...)`.
local function navigate_to(handle, target_path)
local entries = read_entries(target_path, handle.sort_mode)
handle.path = target_path
handle.entries = entries
paint(handle)
end
-- Re-read the current path (no navigation). Same atomic guarantee.
local function refresh(handle)
navigate_to(handle, handle.path)
end
-- ---------------------------------------------------------------------------
-- Public: open a directory
-- ---------------------------------------------------------------------------
-- Open `path` in a fresh dired buffer. Must be called from inside
-- a `pmacs.async(...)` body because read_dir is async. Returns
-- the handle (mostly for testing; user code rarely needs it).
--
-- The directory is read before any editor-visible side effects. If
-- read_dir fails, no empty dired buffer is created and no stale
-- handle is registered.
--
-- `path` must be absolute. parent_path() and the open-line
-- navigation both assume absolute paths; accepting relative paths
-- would silently produce wrong parents (e.g. parent of `.` -> `/`).
-- The caller can resolve via `pmacs.fs.realpath` (when that
-- ships) or just pass an absolute string.
function M.open(path)
if type(path) ~= "string" then
error("pmacs-dired.open: path must be a string, got " .. type(path))
end
if path:sub(1, 1) ~= "/" then
error("pmacs-dired.open: path must be absolute (start with '/'); got: " .. path)
end
local sort_mode = "name"
local entries = read_entries(path, sort_mode)
local buf = pmacs.buffer.create("*dired:" .. path .. "*")
local handle = {
buf = buf,
path = path,
sort_mode = sort_mode,
entries = entries,
}
handles[#handles + 1] = handle
paint(handle)
pmacs.window.switch_buffer(buf)
pmacs.keymap.bind {
scope = "buffer", buffer = buf, sequence = "RET",
command = "pmacs-dired.open-line",
}
pmacs.keymap.bind {
scope = "buffer", buffer = buf, sequence = "Backspace",
command = "pmacs-dired.parent",
}
return handle
end
-- ---------------------------------------------------------------------------
-- Path manipulation
-- ---------------------------------------------------------------------------
-- Compute the parent of an absolute path. `/foo/bar` -> `/foo`,
-- `/foo` -> `/`, `/` -> `/` (root is its own parent --- the user
-- can't navigate further up).
local function parent_path(path)
if path == "/" then return "/" end
-- Strip trailing slash if any (defensive: path could've been
-- canonicalised by the user differently).
local p = path:gsub("/$", "")
-- Greedy `/[^/]*` match strips the last segment.
local stripped = p:gsub("/[^/]*$", "")
if stripped == "" then return "/" end
return stripped
end
-- Join `dir` and `name` into an absolute path. `/foo` + `bar`
-- -> `/foo/bar`; `/` + `bar` -> `/bar`.
local function join_path(dir, name)
if dir:sub(-1) == "/" then return dir .. name end
return dir .. "/" .. name
end
-- ---------------------------------------------------------------------------
-- Cursor -> entry resolution
-- ---------------------------------------------------------------------------
-- Map the active window's cursor line to the entry on that line.
-- Line 0 is the header; lines >= 1 are entries[1..]. Returns the
-- entry table or nil if the cursor is on the header (or out of
-- range).
local function entry_at_cursor(handle)
local line = pmacs.editor.cursor_line()
if line < 1 then return nil end
return handle.entries[line]
end
-- ---------------------------------------------------------------------------
-- Wdired layer (T M8.3): editable buffer maps to rename / chmod
-- ---------------------------------------------------------------------------
--
-- A dired buffer has two modes:
--
-- * read-only (default): no intercept; navigation commands work;
-- the buffer is not user-writable in the normal sense (paint()
-- overwrites it on every refresh, so any user edit would be
-- clobbered).
--
-- * wdired-edit (this section): an intercept_edit chain entry is
-- attached to the buffer that constrains user edits to two
-- editable column ranges per entry line --- the perms column
-- (bytes 1..10 of the line) and the name column (bytes 39..end-
-- of-line). Every other position is read-only and rejected at
-- intercept time. Edits within the perms region must come from
-- the rwx alphabet, also rejected at intercept time.
--
-- Render layout per entry line, byte by byte:
--
-- col 0 kind char ('d', 'l', '-', '?') -- ro
-- col 1..9 (incl) perms (9 ch) ("rwxr-xr-x") -- editable
-- col 10 space (" ") -- ro
-- col 11..20 (incl) size %10d (" 1234") -- ro
-- col 21 space -- ro
-- col 22..37 (incl) mtime ("2026-05-05 12:34") -- ro
-- col 38 space -- ro
-- col 39..eol name ("foo.txt") -- editable
--
-- (Symlinks have ` -> target` appended in the name column. The
-- editable region runs to end-of-line for files and directories;
-- on a symlink line, only the basename portion is meaningfully
-- editable --- the trailing ` -> target` suffix and the perms
-- column are both gated by commit-time / intercept-time checks
-- because chmod follows symlinks and target reconfiguration is
-- out of scope for v0.1.)
--
-- Commit translates buffer state → filesystem ops:
-- - name changed → pmacs.fs.rename(old_path, new_path)
-- - perms changed → pmacs.fs.chmod(path, new_mode)
--
-- Renames are routed through unique temp names within the same
-- directory (two-phase) so swaps and chains commit safely without
-- per-pair ordering analysis; `rename(2)` would otherwise replace
-- existing targets atomically.
--
-- External changes between edit-toggle and commit are detected by
-- re-reading the directory and comparing every snapshot field
-- (count, names, mode, kind, size, mtime seconds+nsec, symlink
-- target) against the current state. Any divergence aborts the
-- commit with a message pointing at `pmacs-dired.wdired-abandon`,
-- which discards
-- pending edits and refreshes the listing from disk.
-- Layout constants ---------------------------------------------------------
local KIND_BYTES = 1
local PERMS_BYTES = 9
local SIZE_BYTES = 10
local MTIME_BYTES = 16
-- Computed offsets within an entry line (start positions, inclusive).
local PERMS_START = KIND_BYTES -- 1
local PERMS_END = PERMS_START + PERMS_BYTES -- 10 (exclusive)
local NAME_START = PERMS_END + 1 + SIZE_BYTES + 1 + MTIME_BYTES + 1 -- 39
-- Permission-character validation ------------------------------------------
local function is_perm_byte(b)
return b == 45 -- '-'
or b == 114 -- 'r'
or b == 119 -- 'w'
or b == 120 -- 'x'
end
local function bytes_are_all_perm_chars(s)
for i = 1, #s do
if not is_perm_byte(s:byte(i)) then return false end
end
return true
end
-- Parse a 9-char permission string into a numeric mode. Returns the
-- mode integer (0..0o777) on success, or nil + reason on failure.
-- The check is *positional*: byte 0 must be 'r' or '-', byte 1 must
-- be 'w' or '-', byte 2 must be 'x' or '-', repeating per triple.
-- Setuid / setgid / sticky bits are not surfaced --- v0.1 wdired
-- only edits the basic 9 mode bits.
local function parse_perm_string(s)
if #s ~= 9 then
return nil, ("perms column must be exactly 9 chars; got " .. #s)
end
local mode = 0
local triples = { { 64, 32, 16 }, { 8, 4, 2 }, { 4, 2, 1 } }
-- triples[i] = bit values for triple i; we'll compute owner/group/other separately
-- Owner (bytes 0..2) -> 0o400, 0o200, 0o100
-- Group (bytes 3..5) -> 0o040, 0o020, 0o010
-- Other (bytes 6..8) -> 0o004, 0o002, 0o001
local bit_values = { 256, 128, 64, 32, 16, 8, 4, 2, 1 }
local expected = { "r", "w", "x", "r", "w", "x", "r", "w", "x" }
for i = 1, 9 do
local ch = s:sub(i, i)
if ch == expected[i] then
mode = mode + bit_values[i]
elseif ch ~= "-" then
return nil, ("invalid perms char '" .. ch .. "' at position " ..
tostring(i) .. " (expected '" .. expected[i] .. "' or '-')")
end
end
return mode
end
-- Buffer line resolution ---------------------------------------------------
-- Find the entry index for an absolute byte position. Returns
-- (idx, line_start_pos, line_end_pos_exclusive) or nil if pos is in
-- the header or past the last entry. line_end_pos_exclusive points
-- to the trailing '\n' (or buf:len() for the last entry).
local function entry_at_byte(handle, pos)
local edit = handle.edit
if not edit then return nil end
local marks = edit.line_start_marks
local n = #marks
if n == 0 then return nil end
-- Linear scan: typical wdired use is small (dozens of entries).
-- For a 10K-entry dired the user typically wouldn't go into
-- wdired-edit; if they do, scan cost is still negligible compared
-- to the user's typing speed.
local last_idx = nil
local last_start = nil
for i = 1, n do
local s = marks[i]:get()
if pos < s then break end
last_idx, last_start = i, s
end
if not last_idx then return nil end
local line_end
if last_idx < n then
line_end = marks[last_idx + 1]:get() - 1 -- the '\n' position
else
line_end = handle.buf:len()
end
return last_idx, last_start, line_end
end
-- Classify a writable region by absolute byte range. `start_pos` is
-- inclusive; `end_pos` is exclusive (the half-open convention used
-- by EditOp). Returns "perms" or "name" if the range fits entirely
-- in one writable column of an entry line; returns nil if the range
-- crosses lines, lands in a read-only column, or would change the
-- perms-column width.
--
-- Perms is fixed-width (9 chars). Inserts and deletes inside it
-- shift adjacent columns and would force every consumer (the
-- intercept body, the commit parser, the line-reader) to handle
-- variable-width perms. We forbid that at the edge: a zero-length
-- range (insert) NEVER classifies as "perms" --- inserts are valid
-- only inside the variable-width name column. Deletes and replaces
-- can land in perms; the caller (the intercept body) enforces
-- length preservation for those.
local function classify_writable_range(handle, start_pos, end_pos)
local zero_len = (start_pos == end_pos)
local idx, line_start, line_end = entry_at_byte(handle, start_pos)
if not idx then return nil end
-- The end of the range must be in the same line.
if start_pos > line_end or end_pos > line_end then
return nil
end
local rel_start = start_pos - line_start
local rel_end = end_pos - line_start
-- name region: [NAME_START, eol). Inserts and deletes are
-- length-changing here (variable-width column); that's fine.
if rel_start >= NAME_START then
return "name"
end
-- perms region: [PERMS_START, PERMS_END). Inserts disallowed
-- (they'd extend perms past 9 chars). Deletes / replaces must
-- stay strictly inside the column.
if not zero_len and rel_start >= PERMS_START and rel_end <= PERMS_END then
return "perms"
end
return nil
end
-- The intercept body itself --------------------------------------------------
-- Reject any perms-column edit that lands on a symlink line. The
-- core fs surface documents that chmod follows symlinks (per
-- `chmod(2)`); on most filesystems a symlink reports 0o777
-- regardless of mode bits applied to it, and a chmod through the
-- link would silently mutate the *target* file's mode. The user's
-- displayed perms come from lstat (the link's metadata), so they
-- can't tell which file they're about to modify. Rather than
-- offering a knob with surprising semantics, v0.1 wdired makes
-- the perms column on a symlink line read-only at intercept time.
-- A future milestone could surface lchmod (where supported) or
-- offer an explicit "edit target perms" command.
local function reject_if_symlink_perms(handle, range_start, op_label)
local idx = entry_at_byte(handle, range_start)
if idx and handle.edit.snapshot[idx].kind == "symlink" then
error("pmacs-dired wdired: " .. op_label .. " on a symlink line's " ..
"perms column is not supported (chmod follows symlinks; " ..
"editing the displayed link perms would silently mutate the " ..
"target file's mode while leaving the link's lstat perms " ..
"unchanged --- v0.1 wdired only edits names on symlink lines)")
end
end
local function make_intercept_body(handle)
return function(op)
-- Pass through the package's own paint operations.
if handle.painting then return nil end
-- If the buffer is mid-edit teardown (commit or abandon flipped
-- handle.edit to nil but the intercept is still attached for a
-- frame), fail closed: reject the edit. The caller can retry
-- once the intercept is detached.
if not handle.edit then
error("pmacs-dired: edit teardown in progress; retry after commit/abandon completes")
end
local kind = op.kind
if kind == "insert" then
local pos = op.pos
local bytes = op.bytes or ""
-- Reject any insert containing a newline --- it would split a
-- single entry line into two physical lines, breaking the
-- one-line-per-entry contract.
if bytes:find("\n", 1, true) then
error("pmacs-dired wdired: newline insertions are not allowed " ..
"(would split entry line " .. tostring(pos) .. ")")
end
local region = classify_writable_range(handle, pos, pos)
if region == nil then
error("pmacs-dired wdired: insert at byte " .. tostring(pos) ..
" is in a read-only column (perms is fixed-width 9 chars " ..
"and accepts only same-length replaces; only the name " ..
"column accepts inserts)")
end
-- region is "name"; classify_writable_range never returns
-- "perms" for a zero-length range.
elseif kind == "delete" then
local s, e = op.start, op["end"]
local region = classify_writable_range(handle, s, e)
if region == nil then
error("pmacs-dired wdired: deletion of bytes [" .. tostring(s) ..
", " .. tostring(e) .. ") crosses or enters a read-only column " ..
"(only the perms and name columns are editable)")
end
if region == "perms" then
error("pmacs-dired wdired: deletes inside the perms column are " ..
"not allowed (perms is a fixed-width 9-char field; use a " ..
"same-length replace to change permission bits)")
end
elseif kind == "replace" then
local s, e = op.start, op["end"]
local bytes = op.bytes or ""
if bytes:find("\n", 1, true) then
error("pmacs-dired wdired: replacement bytes contain a newline " ..
"(would split entry line)")
end
local region = classify_writable_range(handle, s, e)
if region == nil then
error("pmacs-dired wdired: replacement of bytes [" .. tostring(s) ..
", " .. tostring(e) .. ") crosses or enters a read-only column")
end
if region == "perms" then
reject_if_symlink_perms(handle, s, "replace")
local range_len = e - s
if #bytes ~= range_len then
error("pmacs-dired wdired: perms column edits must preserve the " ..
"9-char width; got replacement of " .. tostring(range_len) ..
" bytes with " .. tostring(#bytes) .. " bytes")
end
if not bytes_are_all_perm_chars(bytes) then
error("pmacs-dired wdired: perms column accepts only 'r', 'w', " ..
"'x', and '-'; got " .. string.format("%q", bytes))
end
end
end
return nil -- pass-through for accepted edits
end
end
-- Edit-mode entry / exit ----------------------------------------------------
-- Snapshot the entries and place a mark at each entry line's start.
-- `painting` must be false when this runs (we don't want our own
-- paint to be re-validated by the intercept we're about to attach).
local function setup_edit_state(handle)
local edit = {
snapshot = {},
line_start_marks = {},
intercept = nil,
}
-- Header is "<path>:" + "\n". The first entry's line starts at
-- byte (#header_text + 1) where header_text = handle.path .. ":".
local pos = #handle.path + 1 + 1
for i, e in ipairs(handle.entries) do
-- Defensive deep copy of the fields we rely on at commit. The
-- snapshot is what we diff edits against; it must not shift
-- under us if anything else mutates handle.entries[i].
edit.snapshot[i] = {
name = e.name,
mode = e.mode,
kind = e.kind,
size = e.size,
mtime = e.mtime,
mtime_nsec = e.mtime_nsec or 0,
symlink_target = e.symlink_target,
}
edit.line_start_marks[i] = pmacs.buffer.mark_create(
handle.buf, pos, { gravity = "right" }
)
pos = pos + #render_entry(e) + 1 -- +1 for the trailing \n
end
return edit
end
local function teardown_edit_state(handle)
local edit = handle.edit
if not edit then return end
if edit.intercept then
pmacs.buffer.remove_intercept(edit.intercept)
edit.intercept = nil
end
for _, mark in ipairs(edit.line_start_marks) do
mark:remove()
end
handle.edit = nil
end
-- Read the current rendered text of entry line i (1-indexed).
-- Returns the full line text (without the trailing newline).
local function read_line_text(handle, idx)
local edit = handle.edit
local marks = edit.line_start_marks
local s = marks[idx]:get()
local e
if idx < #marks then
e = marks[idx + 1]:get() - 1 -- before the '\n'
else
e = handle.buf:len()
end
return handle.buf:slice(s, e)
end
-- Parse one rendered line into its (perms_str, name) pair, OR
-- return nil + reason if the line is malformed for commit purposes.
-- We expect: kind(1) + perms(9) + " " + size(10) + " " + mtime(16)
-- + " " + name(rest). Spaces at the fixed offsets must still be
-- spaces (the intercept enforces that the user can't have edited
-- them, but we re-verify here as a defense-in-depth check before
-- producing rename ops).
local function parse_committed_line(line)
if #line < NAME_START then
return nil, "line too short for an entry (header was edited?)"
end
if line:byte(PERMS_END + 1) ~= 32 -- byte 10 must be ' '
or line:byte(PERMS_END + 1 + SIZE_BYTES + 1) ~= 32 -- byte 21
or line:byte(NAME_START) ~= 32 then -- byte 38
return nil, "fixed-width separators between columns were modified"
end
local perms_str = line:sub(PERMS_START + 1, PERMS_END) -- 1-indexed slice
local name = line:sub(NAME_START + 1) -- everything from byte 39 onward
return perms_str, name
end
-- Decode a rendered (escaped) name from the buffer back into the
-- real basename bytes. Returns (real_basename, nil) on success or
-- (nil, error) on a malformed line.
--
-- For a symlink, the rendered line is "<basename> -> <target>". We
-- enforce that the user didn't change <target>: v0.1 supports
-- name renames and chmods, not symlink target reconfiguration.
-- (If the user wants to repoint a symlink, they must remove + re-
-- create it; the M8.3 spec doesn't promise a target-edit path.)
-- Fail loudly here rather than silently drop the user's target
-- edit, which is what the previous implementation did.
--
-- We strip the *expected* trailing suffix (computed from the
-- snapshot's target) rather than splitting at the first ` -> `:
-- a basename containing the literal ` -> ` (which is a perfectly
-- legal Unix filename) renders as `a -> b -> target`, and the
-- naive first-arrow split would mistake `b -> target` for a
-- target edit. Suffix-stripping makes the round-trip work
-- regardless of internal arrows.
--
-- For non-symlinks the rendered name is just the escaped basename;
-- we run it through unescape_displayable so that real \n / \t /
-- \xNN bytes round-trip without producing spurious renames.
local function decode_committed_name(rendered_name, snap)
local basename = rendered_name
if snap.kind == "symlink" then
local expected_suffix = " -> " ..
escape_displayable(snap.symlink_target or "")
local sl = #expected_suffix
if #rendered_name < sl or rendered_name:sub(-sl) ~= expected_suffix then
return nil, ("symlink target edits are not supported in v0.1 " ..
"(expected the line to end with '" .. expected_suffix ..
"'); restore the original target text and retry, or " ..
"remove + re-create the symlink outside dired")
end
basename = rendered_name:sub(1, #rendered_name - sl)
end
local real_basename, uerr = unescape_displayable(basename)
if not real_basename then
return nil, uerr
end
return real_basename, nil
end
-- Detect external changes since edit-toggle. Re-reads the
-- directory and compares each entry against the snapshot field-by-
-- field: count, names, mode, kind, size, mtime seconds+nsec,
-- symlink target.
-- Returns nil on no change or a string error on first divergence.
--
-- Why all fields, not just names: the previous implementation
-- compared only count + name set, so an external chmod, an
-- external truncate, a same-size rewrite, or a kind switch (file
-- replaced by symlink with the same name) would slip through and
-- the commit would happily overwrite or misrepresent the
-- post-external-change state.
local function detect_external_changes(handle)
local current
local ok, err = pcall(function()
current = pmacs.fs.read_dir(handle.path):await()
end)
if not ok then
return "could not re-read '" .. handle.path .. "' to verify: " .. tostring(err)
end
local snapshot = handle.edit.snapshot
if #current ~= #snapshot then
return ("entry count changed from " .. #snapshot .. " to " .. #current ..
" (external add/remove since edit started)")
end
-- Build name -> snapshot-entry map for O(1) lookup.
local snap_by_name = {}
for _, e in ipairs(snapshot) do snap_by_name[e.name] = e end
for _, c in ipairs(current) do
local s = snap_by_name[c.name]
if not s then
return ("external rename or replacement detected: '" .. c.name ..
"' was not present at edit-start")
end
if c.mode ~= s.mode then
return ("external mode change on '" .. c.name ..
"': was 0o" .. string.format("%o", s.mode) ..
", now 0o" .. string.format("%o", c.mode))
end
if c.kind ~= s.kind then
return ("external kind change on '" .. c.name ..
"': was '" .. tostring(s.kind) ..
"', now '" .. tostring(c.kind) .. "'")
end
if c.size ~= s.size then
return ("external size change on '" .. c.name ..
"': was " .. tostring(s.size) ..
", now " .. tostring(c.size))
end
if c.mtime ~= s.mtime or (c.mtime_nsec or 0) ~= (s.mtime_nsec or 0) then
return ("external mtime change on '" .. c.name ..
"' (file was rewritten or touched)")
end
if (c.symlink_target or "") ~= (s.symlink_target or "") then
return ("external symlink target change on '" .. c.name ..
"': was '" .. tostring(s.symlink_target) ..
"', now '" .. tostring(c.symlink_target) .. "'")
end
end
return nil
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
--
-- We track every name we register so the on_unload hook can hand
-- the slots back to the registry. Without this, a second chunk run
-- (install_local replacement, reload) hits DuplicateName.
local OWNED_COMMANDS = {}
local function define_owned(spec)
pmacs.command.define(spec)
OWNED_COMMANDS[#OWNED_COMMANDS + 1] = spec.name
end
-- Reject navigation while a wdired session is open. The user has
-- pending edits that would be silently discarded by a refresh.
local function ensure_not_editing(h, op_name)
if h and h.edit then
error("pmacs-dired: " .. op_name .. " is not available while wdired-edit " ..
"is active; commit (M-x pmacs-dired.wdired-commit) or abandon " ..
"(M-x pmacs-dired.wdired-abandon) first.")
end
end
define_owned {
name = "pmacs-dired.open-line",
description = "Open the directory under the cursor in the active dired buffer.",
fn = function()
local h = active_handle()
if not h then return end
ensure_not_editing(h, "pmacs-dired.open-line")
local entry = entry_at_cursor(h)
if not entry then return end
if entry.kind == "dir" then
-- Navigate: hand the target to navigate_to, which reads
-- before committing path/entries/paint. Failure here leaves
-- the buffer showing the current listing.
local target = join_path(h.path, entry.name)
pmacs.async(function()
navigate_to(h, target)
end)
else
-- Files / symlinks: opening these requires a buffer-from-file
-- primitive that pmacs hasn't exposed yet. We could synthesize
-- one by reading the contents into a fresh buffer, but the
-- editor's file-load path also does encoding detection, mode
-- selection, and dirty-tracking --- reproducing that surface
-- inside this package would duplicate logic destined for the
-- core. v0.1 errors with a clear message; the wdired layer
-- (T M8.3) revisits this.
error(
"pmacs-dired.open-line: opening files from dired requires the " ..
"buffer-from-file API (not yet exposed). Use the editor's " ..
"file-open command (typically C-x C-f) to open '" ..
join_path(h.path, entry.name) .. "' for now."
)
end
end,
}
define_owned {
name = "pmacs-dired.parent",
description = "Navigate the active dired buffer to the parent directory.",
fn = function()
local h = active_handle()
if not h then return end
ensure_not_editing(h, "pmacs-dired.parent")
local target = parent_path(h.path)
pmacs.async(function()
navigate_to(h, target)
end)
end,
}
-- Sort commands. Each sets the handle's mode and re-paints from
-- the cached entry list (no fresh read_dir --- sort is a pure
-- in-memory reorder). The async wrapper is for ergonomic
-- consistency with the other dired commands; sort itself doesn't
-- yield.
local function make_sort_command(mode)
return function()
local h = active_handle()
if not h then return end
ensure_not_editing(h, "pmacs-dired.sort-" .. mode)
h.sort_mode = mode
sort_entries(h.entries, mode)
paint(h)
end
end
define_owned {
name = "pmacs-dired.sort-name",
description = "Sort the active dired buffer alphabetically by filename.",
fn = make_sort_command("name"),
}
define_owned {
name = "pmacs-dired.sort-mtime",
description = "Sort the active dired buffer by modification time, newest first.",
fn = make_sort_command("mtime"),
}
define_owned {
name = "pmacs-dired.sort-size",
description = "Sort the active dired buffer by size, largest first.",
fn = make_sort_command("size"),
}
define_owned {
name = "pmacs-dired.wdired-edit",
description = "Toggle the active dired buffer into editable wdired mode.",
fn = function()
local h = active_handle()
if not h then
error("pmacs-dired.wdired-edit: not in a dired buffer")
end
if h.edit then
error("pmacs-dired.wdired-edit: this buffer is already in wdired-edit mode")
end
h.edit = setup_edit_state(h)
h.edit.intercept = pmacs.buffer.add_intercept(
h.buf, make_intercept_body(h)
)
end,
}
define_owned {
name = "pmacs-dired.wdired-abandon",
description = "Discard wdired edits and refresh the dired listing from disk.",
fn = function()
local h = active_handle()
if not h or not h.edit then return end
-- Detach intercept first so paint() isn't blocked by it. The
-- immediate paint() repaints from the cached snapshot so the
-- user instantly sees a non-edit view; the async navigate_to
-- below then re-reads the directory so any external changes
-- (which is precisely the case wdired-commit's error
-- guidance points users here for) are reflected. If the
-- async refresh fails, the cached paint stays visible and
-- the failure surfaces via the modeline.
teardown_edit_state(h)
paint(h)
pmacs.async(function()
local ok, err = pcall(navigate_to, h, h.path)
if not ok and pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status(
"pmacs-dired.wdired-abandon: refresh failed: " .. tostring(err)
)
end
end)
end,
}
-- The synchronous (inside-async) commit body. Raises on any error;
-- the wdired-commit command wraps this in pcall so the outcome can
-- be observed via `handle.last_commit_outcome` even when the work
-- was scheduled by a fire-and-forget pmacs.async.
local function do_wdired_commit(h, progress)
progress = progress or { disk_touched = false }
-- Bullet 4: detect external changes before producing ops. Any
-- mismatch (count, names, mode, kind, size, mtime, symlink
-- target) aborts the commit before we touch disk.
local changed = detect_external_changes(h)
if changed then
error(changed ..
"; run pmacs-dired.wdired-abandon to discard your edits " ..
"and refresh the listing from disk")
end
-- Phase 1: read every line, parse, decode, validate, and build
-- per-entry chmod / rename plans. We collect all ops first and
-- only execute after every line has parsed cleanly --- a
-- malformed line means the commit aborts with no syscalls.
local snapshot = h.edit.snapshot
local final_names = {} -- index-aligned with snapshot
local chmods = {} -- list of { from_basename, mode }
local renames = {} -- list of { from_basename, to_basename }
for i, snap in ipairs(snapshot) do
local line = read_line_text(h, i)
local perms_str, rendered_name = parse_committed_line(line)
if not perms_str then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "') is malformed: " ..
tostring(rendered_name))
end
local mode, perr = parse_perm_string(perms_str)
if not mode then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "') has invalid perms: " .. perr)
end
local real_basename, derr = decode_committed_name(rendered_name, snap)
if not real_basename then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "'): " .. derr)
end
if real_basename == "" then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "') has an empty name")
end
if real_basename:find("/", 1, true) then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "') name contains '/' " ..
"(directory separator not allowed in a basename)")
end
-- POSIX names cannot contain NUL, but unescape_displayable
-- happily accepts \x00 escapes. If we let a NUL through, the
-- chmod-then-rename ordering would chmod the file, then the
-- rename syscall would fail at the kernel boundary --- a
-- partial commit. Reject here, before any syscall fires.
if real_basename:find("\0", 1, true) then
error("line " .. tostring(i) ..
" (entry '" .. snap.name .. "') name contains a NUL byte " ..
"(POSIX filenames cannot contain NUL)")
end
final_names[i] = real_basename
-- Preserve snapshot's high bits (setuid/setgid/sticky and the
-- filetype bits stat returned). parse_perm_string only sets
-- the low 9 bits; OR them in.
local snap_high_bits = math.floor(snap.mode / 512) * 512
local target_mode = snap_high_bits + mode
if target_mode ~= snap.mode then
chmods[#chmods + 1] = { from_basename = snap.name, mode = target_mode }
end
if real_basename ~= snap.name then
renames[#renames + 1] = { from_basename = snap.name,
to_basename = real_basename }
end
end
-- Reject duplicate final names: two entries can't end up named
-- the same. Catches both the "rename a -> b where b is unchanged"
-- overwrite case and the "two lines have the same edited name"
-- typo. (The swap case --- a -> b, b -> a --- has unique final
-- names; that's handled below by the temp-name two-phase rename.)
do
local seen = {}
for i, name in ipairs(final_names) do
local prev = seen[name]
if prev then
error("commit aborted: line " .. tostring(i) ..
" (entry '" .. snapshot[i].name ..
"') would collide with line " .. tostring(prev) ..
" (entry '" .. snapshot[prev].name ..
"'); both have final name '" .. name .. "'")
end
seen[name] = i
end
end
-- Run chmods on the snapshot's old paths first. After this
-- block, the file's mode bits are updated; the rename phase
-- below moves the now-modified file to its final name (if it
-- has a rename pending). The opposite order would either chmod
-- a non-existent old path or chmod the new path before the
-- user's rename intent has been recorded, both surprising.
for _, c in ipairs(chmods) do
pmacs.fs.chmod(join_path(h.path, c.from_basename), c.mode):await()
progress.disk_touched = true
end
-- Two-phase rename: route every rename through a unique temp
-- name in the same directory. This makes swaps (a -> b, b -> a)
-- and chains (a -> b, b -> c, c -> a) safe without a per-pair
-- analysis. POSIX rename() replaces an existing target
-- atomically, so a direct rename(a, b) where b is in the
-- snapshot would silently destroy b; the temp-name detour
-- avoids that entirely. The cost is one extra rename per
-- renamed entry, which on an in-directory rename is a constant-
-- time path-table tweak --- negligible.
if #renames > 0 then
-- Build a unique prefix that doesn't collide with any
-- snapshot or planned final name.
local prefix = ".pmacs-wdired-tmp-" ..
tostring(os.time()) .. "-" ..
tostring(math.random(1, 1000000))
local plen = #prefix
local function prefix_taken()
for _, snap in ipairs(snapshot) do
if snap.name:sub(1, plen) == prefix then return true end
end
for _, name in ipairs(final_names) do
if name:sub(1, plen) == prefix then return true end
end
return false
end
if prefix_taken() then
error("internal: temp-name prefix '" .. prefix ..
"' collides with an entry in '" .. h.path ..
"'; rename the colliding entry outside dired and retry")
end
-- Phase 1: each renamed source -> a unique temp name.
for i, r in ipairs(renames) do
r.tmp_basename = prefix .. "-" .. tostring(i)
pmacs.fs.rename(
join_path(h.path, r.from_basename),
join_path(h.path, r.tmp_basename)
):await()
progress.disk_touched = true
end
-- Phase 2: each temp -> its final name. Ordering is irrelevant
-- now: every target name is unique (collision check above) and
-- no source overlaps any target (sources are temp names).
for _, r in ipairs(renames) do
pmacs.fs.rename(
join_path(h.path, r.tmp_basename),
join_path(h.path, r.to_basename)
):await()
progress.disk_touched = true
end
end
-- Tear down edit state. We *don't* refresh from disk here ---
-- that's the wdired-commit command's job, after observing that
-- we returned without raising. Reason: a refresh failure (e.g.,
-- the parent directory was removed between our last syscall and
-- the refresh read_dir) must not flip a successfully-applied
-- commit to outcome="failed", because the disk changes are real
-- and the user's intent was honored. The command splits those
-- two outcomes into "ok" and "applied; refresh failed: ...".
teardown_edit_state(h)
end
define_owned {
name = "pmacs-dired.wdired-commit",
description = "Apply wdired edits as filesystem rename / chmod ops and refresh.",
fn = function()
-- Outer-shape contract: this command can be invoked from any
-- context (M-x dispatch, keybinding, test eval). It schedules a
-- pmacs.async that does the real work. The outcome is reported
-- two ways:
--
-- 1. handle.last_commit_outcome --- nil while pending, "ok"
-- on success, "failed: <msg>" on validation / pre-syscall
-- failure, "partially applied: <msg>; ..." when at least
-- one filesystem op landed before a later op failed, or
-- "applied; refresh failed: <msg>" when disk ops succeeded
-- but the cosmetic refresh failed. This is what automated
-- tests poll.
-- 2. pmacs.editor.set_status (when available) --- a one-line
-- modeline message. This is what interactive users see.
--
-- pmacs.async surfaces uncaught coroutine errors via
-- pmacs.error rather than re-raising to the caller, so a bare
-- pcall around invoke() wouldn't observe a commit failure.
-- Wrapping the work in pcall *inside* the async body and
-- writing the outcome to the handle is the canonical way for
-- async ops to report results back to non-async callers.
local h = active_handle()
if not h or not h.edit then
error("pmacs-dired.wdired-commit: not in wdired-edit mode")
end
if h.commit_pending then
error("pmacs-dired.wdired-commit: commit already in progress")
end
h.last_commit_outcome = nil
h.commit_pending = true
pmacs.async(function()
-- Phase A: validation + syscalls. A failure here means the
-- commit was aborted at planning (before any syscall) or
-- partway through the rename/chmod batch. Once any syscall has
-- succeeded, a later failure is not a clean rejection: report
-- it as partial application, tear down edit mode, and try to
-- refresh so the buffer reflects disk.
local progress = { disk_touched = false }
local ok, err = pcall(do_wdired_commit, h, progress)
if not ok then
local outcome
if progress.disk_touched then
if h.edit then
teardown_edit_state(h)
end
local ok_refresh, refresh_err = pcall(navigate_to, h, h.path)
outcome = "partially applied: " .. tostring(err)
if ok_refresh then
outcome = outcome .. "; refreshed"
else
outcome = outcome .. "; refresh failed: " .. tostring(refresh_err)
end
else
outcome = "failed: " .. tostring(err)
end
h.last_commit_outcome = outcome
h.commit_pending = false
if pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status("pmacs-dired.wdired-commit: " .. outcome)
end
return
end
-- Phase B: refresh the buffer from disk. The disk side has
-- already settled; this read is purely cosmetic. Don't fold
-- a refresh failure into "failed:" --- that would tell the
-- user their changes were rejected when in fact they're
-- already applied. Use a distinct outcome so callers (and
-- tests) can tell the two apart.
local ok2, err2 = pcall(navigate_to, h, h.path)
if ok2 then
h.last_commit_outcome = "ok"
h.commit_pending = false
if pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status("pmacs-dired.wdired-commit: applied")
end
else
h.last_commit_outcome = "applied; refresh failed: " .. tostring(err2)
h.commit_pending = false
if pmacs.editor and pmacs.editor.set_status then
pmacs.editor.set_status(
"pmacs-dired.wdired-commit: applied (refresh failed: " ..
tostring(err2) .. ")"
)
end
end
end)
end,
}
-- ---------------------------------------------------------------------------
-- Cleanup on unload
-- ---------------------------------------------------------------------------
--
-- Reload-time hook: drop all handles, then unregister every command
-- this package owns. The buffers themselves are owned by the
-- editor; we don't try to destroy them. The next `M.open(...)` call
-- after reload starts with a fresh handle list, and the second
-- chunk run re-defines the commands without colliding.
pmacs.packages.on_unload(function()
-- Tear down any active wdired sessions so their intercepts don't
-- linger on buffers after the package is reloaded.
for _, h in ipairs(handles) do
if h.edit then
teardown_edit_state(h)
end
end
handles = {}
for _, name in ipairs(OWNED_COMMANDS) do
-- pmacs.command.unregister returns a bool, never errors on
-- missing names. Discard the bool: a missing command means
-- something else already cleared it, which is fine.
pmacs.command.unregister(name)
end
OWNED_COMMANDS = {}
end)
-- ---------------------------------------------------------------------------
-- Test seam
-- ---------------------------------------------------------------------------
--
-- The acceptance suite needs to query a handle's state without
-- relying on cursor placement (the v0.1 buffer surface doesn't
-- expose move_to_byte yet, so tests can't reliably position the
-- cursor on a specific line). The `_test` table is convention-
-- private: not stable, not documented, and not intended for other
-- packages. v0.1 audit lint does not enforce field-level privacy on
-- an exported module table, so external authors must treat this as a
-- "do not use" test seam until a stricter lint or test-only export
-- mechanism exists.
M._test = {
active_handle = active_handle,
parent_path = parent_path,
sort_modes = SORT_MODES,
escape_displayable = escape_displayable,
unescape_displayable = unescape_displayable,
navigate_to = navigate_to,
paint = paint,
-- Wdired internals (T M8.3) that the acceptance suite exercises
-- directly so it doesn't have to drive every code path through
-- the command surface.
parse_perm_string = parse_perm_string,
parse_committed_line = parse_committed_line,
decode_committed_name = decode_committed_name,
is_perm_byte = is_perm_byte,
PERMS_START = PERMS_START,
PERMS_END = PERMS_END,
NAME_START = NAME_START,
}
return M