-- builtin/commands/default.lua --- M2.5 default commands. -- -- Defines named, introspectable commands for every M1 editor action. -- The bodies delegate to `pmacs.editor.*` (Rust primitives) so users -- can compose them or rebind their keys without touching the binary. -- The default keymap (builtin/keymaps/default.lua) maps chord -- sequences onto these names. local cmd = pmacs.command.define local ed = pmacs.editor -- Cursor motion -------------------------------------------------------------- cmd { name = "cursor.left", description = "Move cursor one codepoint left.", fn = function() ed.move_left() end } cmd { name = "cursor.right", description = "Move cursor one codepoint right.", fn = function() ed.move_right() end } cmd { name = "cursor.up", description = "Move cursor up one line, preserving column.", fn = function() ed.move_up() end } cmd { name = "cursor.down", description = "Move cursor down one line, preserving column.", fn = function() ed.move_down() end } cmd { name = "cursor.line-start", description = "Move cursor to start of line.", fn = function() ed.move_line_start() end } cmd { name = "cursor.line-end", description = "Move cursor to end of line.", fn = function() ed.move_line_end() end } cmd { name = "cursor.word-left", description = "Move cursor one word backward.", fn = function() ed.move_word_left() end } cmd { name = "cursor.word-right", description = "Move cursor one word forward.", fn = function() ed.move_word_right() end } cmd { name = "cursor.page-up", description = "Move cursor up by one screenful.", fn = function() ed.move_page_up() end } cmd { name = "cursor.page-down", description = "Move cursor down by one screenful.", fn = function() ed.move_page_down() end } cmd { name = "cursor.paragraph-up", description = "Move cursor backward to the previous paragraph break.", fn = function() ed.move_paragraph_up() end } cmd { name = "cursor.paragraph-down", description = "Move cursor forward to the next paragraph break.", fn = function() ed.move_paragraph_down() end } -- Buffer editing ------------------------------------------------------------- -- CUA region semantics: with an active selection, Backspace / Delete -- consume the region (cursor lands at its start, selection clears). -- `delete_region` returns false when no region is active, so the -- single-codepoint behavior is untouched outside selections. cmd { name = "buffer.delete-backward", description = "Delete the active region, or the codepoint before the cursor.", fn = function() if not ed.delete_region() then ed.backspace() end end } cmd { name = "buffer.delete-forward", description = "Delete the active region, or the codepoint at the cursor.", fn = function() if not ed.delete_region() then ed.delete_forward() end end } cmd { name = "buffer.delete-word-backward", description = "Delete from the cursor back to the start of the previous word.", fn = function() ed.delete_word_backward() end } cmd { name = "buffer.delete-word-forward", description = "Delete from the cursor forward to the end of the next word.", fn = function() ed.delete_word_forward() end } -- Selection-extending motion (CUA-style Shift+motion). Each select-* -- command anchors at the current cursor (if no region is already -- active) and then performs the underlying motion. Plain motion -- commands are unchanged: they preserve existing selections. local function ensure_anchor() if ed.region() == nil then ed.begin_selection(ed.cursor()) end end cmd { name = "cursor.select-left", description = "Extend selection by one codepoint left.", fn = function() ensure_anchor(); ed.move_left() end } cmd { name = "cursor.select-right", description = "Extend selection by one codepoint right.", fn = function() ensure_anchor(); ed.move_right() end } cmd { name = "cursor.select-up", description = "Extend selection upward by one line.", fn = function() ensure_anchor(); ed.move_up() end } cmd { name = "cursor.select-down", description = "Extend selection downward by one line.", fn = function() ensure_anchor(); ed.move_down() end } cmd { name = "cursor.select-word-left", description = "Extend selection by one word left.", fn = function() ensure_anchor(); ed.move_word_left() end } cmd { name = "cursor.select-word-right", description = "Extend selection by one word right.", fn = function() ensure_anchor(); ed.move_word_right() end } cmd { name = "cursor.select-paragraph-up", description = "Extend selection to the previous paragraph break.", fn = function() ensure_anchor(); ed.move_paragraph_up() end } cmd { name = "cursor.select-paragraph-down", description = "Extend selection to the next paragraph break.", fn = function() ensure_anchor(); ed.move_paragraph_down() end } cmd { name = "cursor.select-line-start", description = "Extend selection to start of line.", fn = function() ensure_anchor(); ed.move_line_start() end } cmd { name = "cursor.select-line-end", description = "Extend selection to end of line.", fn = function() ensure_anchor(); ed.move_line_end() end } -- CUA type-over: inserting with an active selection replaces it in a -- SINGLE edit (one undo step — `insert_char_over_region` emits one -- `Replace`, not a `delete` + `insert` pair). Without a selection it -- is a plain insert. The pmacs-gpu frontend relies on this: its -- optimistic-insert path detects an own-window selection and -- round-trips the key so these commands run daemon-side. cmd { name = "buffer.newline", description = "Insert a newline at the cursor, replacing the active region.", fn = function() ed.insert_char_over_region(10) end } cmd { name = "buffer.tab", description = "Insert a tab at the cursor, replacing the active region.", fn = function() ed.insert_char_over_region(9) end } cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor, replacing the active region.", fn = function(codepoint) ed.insert_char_over_region(codepoint) end } -- Incremental search --------------------------------------------------------- -- -- C-s / C-r begin a live in-buffer isearch: the match under the cursor -- highlights as you type, the same key steps to the next/previous -- match, RET accepts (keeping the highlights until the next edit), and -- C-g / Esc restore the pre-search cursor. C-M-s / C-M-r start a regex -- search, and M-r toggles literal <-> regex mid-search (handled in -- Rust). While a search is running every keystroke is intercepted in -- Rust (dispatch_search_key), so these commands only run to *start* a -- search from an idle keymap. ed.search_start(forward, regex). cmd { name = "search.forward", description = "Start an incremental search forward from the cursor.", fn = function() ed.search_start(true, false) end } cmd { name = "search.backward", description = "Start an incremental search backward from the cursor.", fn = function() ed.search_start(false, false) end } cmd { name = "search.forward-regex", description = "Start an incremental regex search forward from the cursor.", fn = function() ed.search_start(true, true) end } cmd { name = "search.backward-regex", description = "Start an incremental regex search backward from the cursor.", fn = function() ed.search_start(false, true) end } -- Query-replace (Arc 2). Two chained minibuffer prompts collect the -- from/to strings (separate history buckets so search patterns and -- replacement text don't mix), then ed.query_replace_start begins the -- core interactive session (y/n/!/./q handled by a dispatcher shadow). -- An empty FROM is rejected (nothing to search); an empty TO is valid -- and means deletion (Q#QR3). local function begin_query_replace(regex) pmacs.minibuffer.read { prompt = regex and "Query replace regexp: " or "Query replace: ", history = "query-replace-from", on_accept = function(from) if from == nil or from == "" then pmacs.editor.set_status("query-replace: empty search string") return end pmacs.minibuffer.read { prompt = string.format( regex and "Query replace regexp %s with: " or "Query replace %s with: ", from), history = "query-replace-to", on_accept = function(to) ed.query_replace_start(from, to or "", regex) end, } end, } end cmd { name = "query-replace", description = "Interactively replace a string from the cursor forward (M-%).", fn = function() begin_query_replace(false) end } cmd { name = "query-replace-regexp", description = "Interactively replace a regexp from the cursor forward (C-M-%).", fn = function() begin_query_replace(true) end } -- History -------------------------------------------------------------------- cmd { name = "buffer.undo", description = "Undo the most recent edit.", fn = function() ed.undo() end } cmd { name = "buffer.redo", description = "Redo the most recently undone edit.", fn = function() ed.redo() end } -- Region operations (M2.12) -------------------------------------------------- cmd { name = "region.delete", description = "Delete the active region (set by mouse drag).", fn = function() if not ed.delete_region() then ed.set_status("no region") end end } cmd { name = "region.cancel", description = "Drop any active selection without changing the cursor.", fn = function() ed.clear_selection() end } -- Clipboard (Q#CM6) ---------------------------------------------------------- -- Copy/cut publish the selection to the OS clipboard (OSC 52 in the TUI, -- arboard in the GPU); paste inserts the in-app slot, which Ctrl-V / -- bracketed paste also refreshes. The default bindings are the Emacs -- kill/yank set (M-w / C-w / C-y, C-x h), which were all free. -- The cut/copy/paste trio delegates to the kill ring (Arc 2): kills -- accumulate, C-y yanks the head, M-y cycles older entries. Resolution -- happens at invoke time, so chunk load order doesn't matter; the -- context menu invokes these by name and inherits the ring for free. cmd { name = "edit.copy", description = "Save the active region to the kill ring (and OS clipboard).", fn = function() pmacs.killring.copy() end } cmd { name = "edit.cut", description = "Kill the active region into the kill ring (and OS clipboard).", fn = function() pmacs.killring.cut() end } cmd { name = "edit.paste", description = "Yank the most recent kill at the cursor, replacing any region.", fn = function() pmacs.killring.yank() end } cmd { name = "edit.select-all", description = "Select the whole buffer.", fn = function() ed.select_all() end } -- File I/O ------------------------------------------------------------------- cmd { name = "buffer.save", description = "Save the current buffer to its backing file.", fn = function() if not pmacs.hook.run("buffer.before-save") then ed.set_status("save vetoed by buffer.before-save") return end -- `ed.save()` refuses when the file changed on disk since this -- buffer read it, rather than clobbering the other writer. It -- reports how to override; `buffer.after-save` must not fire. if ed.save() then pmacs.hook.run("buffer.after-save") end end } cmd { name = "buffer.save-anyway", description = "Save, overwriting a file that changed on disk since it was read.", fn = function() if not pmacs.hook.run("buffer.before-save") then ed.set_status("save vetoed by buffer.before-save") return end if ed.save_ignoring_disk_changes() then pmacs.hook.run("buffer.after-save") end end } -- Editor session ------------------------------------------------------------- cmd { name = "editor.quit", description = "Exit the editor.", fn = function() if not pmacs.hook.run("editor.before-quit") then ed.set_status("quit vetoed by editor.before-quit") return end ed.quit() end } cmd { name = "editor.cancel", description = "Cancel a pending key prefix.", fn = function() ed.cancel() end } -- Window splits (M2.8) ------------------------------------------------------- cmd { name = "window.split-horizontal", description = "Split the active window horizontally (children stack top-to-bottom).", fn = function() pmacs.window.split_horizontal() end } cmd { name = "window.split-vertical", description = "Split the active window vertically (children sit side-by-side).", fn = function() pmacs.window.split_vertical() end } cmd { name = "window.toggle-line-numbers", description = "Toggle the active window's line-number gutter (off / absolute).", fn = function() pmacs.window.set_line_numbers( pmacs.window.line_numbers() == "off" and "absolute" or "off") end } -- Pick a line-number mode directly from the completion dropdown, rather -- than cycling. Arrow-navigable candidates (off/absolute/relative/hybrid). cmd { name = "window.set-line-numbers", description = "Set the active window's line-number mode (off/absolute/relative/hybrid).", fn = function() pmacs.minibuffer.read { prompt = "Line numbers: ", source = function() return { "off", "absolute", "relative", "hybrid" } end, history = "line-numbers", on_accept = function(mode) if mode == nil or mode == "" then return end local ok, err = pcall(pmacs.window.set_line_numbers, mode) if not ok then pmacs.editor.set_status("line-numbers: " .. (tostring(err):match("^[^\n]*") or "")) end end, } end } cmd { name = "window.focus-next", description = "Move focus to the next window in iteration order.", fn = function() pmacs.window.focus_next() end } cmd { name = "window.focus-prev", description = "Move focus to the previous window in iteration order.", fn = function() pmacs.window.focus_prev() end } cmd { name = "window.close", description = "Close the active window (refused when only one remains).", fn = function() if not pmacs.window.close() then pmacs.editor.set_status("only one window left") end end } cmd { name = "window.close-others", description = "Close every window except the active one.", fn = function() pmacs.window.close_others() end } -- Buffer cycling (Doom-style C-x / C-x ) ----------------------- -- -- Walks the active window through the registry's buffer list in a -- ring; `next` wraps from the last buffer to the first, `previous` -- wraps the other way. Compares BufferIdLua handles via tostring() -- because two handles to the same buffer are different userdata -- instances that don't hash equal. local function cycle_buffer(step) local current = pmacs.window.buffer() local ids = pmacs.buffer.list() if #ids <= 1 then return end local cur_str = tostring(current) for i, id in ipairs(ids) do if tostring(id) == cur_str then local target = ((i - 1 + step) % #ids) + 1 pmacs.window.switch_buffer(ids[target]) return end end -- Active buffer somehow isn't in the registry list (shouldn't -- happen). Fall through to the first buffer so the user isn't -- stranded. pmacs.window.switch_buffer(ids[1]) end cmd { name = "editor.next-buffer", description = "Switch the active window to the next buffer in the registry, wrapping at the end.", fn = function() cycle_buffer(1) end } cmd { name = "editor.previous-buffer", description = "Switch the active window to the previous buffer in the registry, wrapping at the start.", fn = function() cycle_buffer(-1) end } -- Buffer list (Emacs Buffer-menu-mode-style) --------------------------------- -- -- `editor.list-buffers` renders one line per registered buffer into a -- regular buffer named `*buffer-list*`, then switches the active -- window to it. Inside that buffer a small per-buffer keymap turns the -- listing into a navigable mode: -- -- RET / SPC : visit the buffer on the current line. -- n / down : next line. p / up : previous line. -- d : mark current line for deletion (renders a `D` in col 0). -- u : unmark. -- x : kill every marked buffer, then refresh. -- k : kill the buffer on the current line immediately. -- q : return to the buffer that was active when the list opened. -- g : refresh the listing. -- -- Marks live in a Lua-side set keyed by `tostring(BufferIdLua)`; the -- `D` is part of the rendering, so any toggle re-renders the buffer. -- Killing routes through `pmacs.buffer.kill`, which redirects any -- window showing the doomed buffer to `*scratch*` first so windows -- never end up pointing at a missing id. local LIST_NAME = "*buffer-list*" local LIST_HEADER = " Buffer Size" local list_state = { buffer_id = nil, -- the *buffer-list* BufferIdLua, once created prev_buffer_id = nil, -- buffer to return to on `q` marks = {}, -- { [tostring(id)] = BufferIdLua } -- mark-for-kill set line_to_buffer = {}, -- 1-based: data line N -> BufferIdLua bound = false, -- buffer-local bindings installed? } local function find_list_buffer() for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == LIST_NAME then return id end end return nil end local function render_list(buf) list_state.line_to_buffer = {} local lines = { LIST_HEADER } for _, id in ipairs(pmacs.buffer.list()) do local d = pmacs.describe.buffer(id) if d ~= nil then local marked = list_state.marks[tostring(id)] ~= nil local mark = marked and "D" or " " local modified = d.modified and "*" or " " table.insert(lines, string.format("%s%s %-30s %d bytes", mark, modified, d.name, d.length)) list_state.line_to_buffer[#lines - 1] = id end end local body = table.concat(lines, "\n") local len = buf:len() if len > 0 then buf:delete(0, len) end if #body > 0 then buf:insert(0, body) end end local function bind_local_keymap(buf) local function bind(seq, command) pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command } end bind("RET", "editor.buffer-list-visit") bind("SPC", "editor.buffer-list-visit") bind("n", "cursor.down") bind("", "cursor.down") bind("p", "cursor.up") bind("", "cursor.up") bind("d", "editor.buffer-list-mark-delete") bind("u", "editor.buffer-list-unmark") bind("x", "editor.buffer-list-execute") bind("k", "editor.buffer-list-kill-now") bind("q", "editor.buffer-list-quit") bind("g", "editor.buffer-list-refresh") end local function ensure_list_buffer() local existing = find_list_buffer() if existing then list_state.buffer_id = existing return existing, false end local buf = pmacs.buffer.create(LIST_NAME) list_state.buffer_id = buf return buf, true end local function current_buffer_at_cursor() local line = pmacs.editor.cursor_line() if line < 1 then return nil end return list_state.line_to_buffer[line] end local function refresh() if list_state.buffer_id == nil then return end -- Drop any marks whose target buffer no longer exists. local pruned = {} for _, id in ipairs(pmacs.buffer.list()) do local key = tostring(id) if list_state.marks[key] then pruned[key] = id end end list_state.marks = pruned -- Wholesale-rewriting the buffer leaves the cursor at a stale byte -- offset (the engine adjusts text-view caches on edit but doesn't -- touch window.cursor). Save the line, rewrite, then re-seat the -- cursor on the same line (clamped to the new data extent). local saved_line = pmacs.editor.cursor_line() render_list(list_state.buffer_id) pmacs.window.switch_buffer(list_state.buffer_id) local data_count = #list_state.line_to_buffer local target = math.min(saved_line, data_count) if target < 1 and data_count >= 1 then target = 1 end for _ = 1, target do pmacs.editor.move_down() end end cmd { name = "editor.list-buffers", description = "Show the buffer list with buffer-menu-mode-style bindings.", fn = function() local active = pmacs.window.buffer() local buf, fresh = ensure_list_buffer() -- Don't overwrite prev_buffer_id when re-entering from inside -- *buffer-list* itself; preserve the original return target. if tostring(active) ~= tostring(buf) then list_state.prev_buffer_id = active end render_list(buf) if fresh or not list_state.bound then bind_local_keymap(buf) list_state.bound = true end pmacs.window.switch_buffer(buf) -- Land on the first data line, not the header. if #list_state.line_to_buffer >= 1 then pmacs.editor.move_down() end end } cmd { name = "editor.buffer-list-visit", description = "Switch to the buffer named on the current *buffer-list* line.", fn = function() local target = current_buffer_at_cursor() if target == nil then pmacs.editor.set_status("not on a buffer line") return end pmacs.window.switch_buffer(target) end } cmd { name = "editor.buffer-list-mark-delete", description = "Mark the buffer on the current line for deletion (column 0 = `D`).", fn = function() local target = current_buffer_at_cursor() if target == nil then pmacs.editor.set_status("not on a buffer line") return end list_state.marks[tostring(target)] = target refresh() pmacs.editor.move_down() end } cmd { name = "editor.buffer-list-unmark", description = "Clear the deletion mark on the current line.", fn = function() local target = current_buffer_at_cursor() if target == nil then pmacs.editor.set_status("not on a buffer line") return end list_state.marks[tostring(target)] = nil refresh() pmacs.editor.move_down() end } cmd { name = "editor.buffer-list-execute", description = "Kill every buffer marked with `D`, then refresh the listing.", fn = function() if list_state.buffer_id == nil then return end local doomed = {} for _, id in pairs(list_state.marks) do if tostring(id) ~= tostring(list_state.buffer_id) then table.insert(doomed, id) end end list_state.marks = {} local killed = 0 for _, id in ipairs(doomed) do local ok = pcall(pmacs.buffer.kill, id) if ok then killed = killed + 1 end end refresh() pmacs.editor.set_status(string.format("killed %d buffer(s)", killed)) end } cmd { name = "editor.buffer-list-kill-now", description = "Kill the buffer on the current line immediately, then refresh.", fn = function() local target = current_buffer_at_cursor() if target == nil then pmacs.editor.set_status("not on a buffer line") return end if tostring(target) == tostring(list_state.buffer_id) then pmacs.editor.set_status("can't kill *buffer-list* from inside itself") return end local ok, err = pcall(pmacs.buffer.kill, target) if not ok then pmacs.editor.set_status("kill failed: " .. tostring(err)) return end list_state.marks[tostring(target)] = nil refresh() end } cmd { name = "editor.buffer-list-quit", description = "Switch the active window back to the buffer that was active when the list opened.", fn = function() local prev = list_state.prev_buffer_id if prev == nil then return end -- The previous buffer may have been killed in the meantime; -- fall back to *scratch* (creating it on demand) so the user -- still gets a valid buffer in the window. local exists = false for _, id in ipairs(pmacs.buffer.list()) do if tostring(id) == tostring(prev) then exists = true; break end end if not exists then local scratch for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*scratch*" then scratch = id; break end end prev = scratch or pmacs.buffer.create("*scratch*") end pmacs.window.switch_buffer(prev) end } cmd { name = "editor.buffer-list-refresh", description = "Re-render the *buffer-list* listing to reflect the current registry.", fn = function() refresh() end } cmd { name = "editor.switch-buffer", description = "Switch the active window to a buffer chosen via M-x-style prompt.", fn = function() pmacs.minibuffer.read { prompt = "Switch to buffer: ", source = "buffers", history = "buffer", on_accept = function(name) if name == nil or name == "" then return end for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == name then pmacs.window.switch_buffer(id) return end end pmacs.editor.set_status("no buffer: " .. name) end, } end } -- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ---------------- -- -- Until now pmacs had no discoverable way to open a file by path: a file -- entered a session only from the CLI, an LSP jump, a project-search -- visit, or `C-x C-r` (whose prompt does pass free text through, but -- completes only over the recent list). This is that surface. -- -- Two substrate facts shape it, and both are load-bearing: -- -- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and -- yields bare basenames (`minibuffer.rs` `list_directory`), capped at -- the shared candidate limit. A custom function source could not do -- better: sources are called with NO arguments, so a callback cannot -- see the input to re-root on, and it runs synchronously outside any -- coroutine, where `Handle:await()` raises --- so it cannot list a -- directory either. Hierarchical completion is a named Rust change in -- the framing, not something this command can fake. -- -- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates` -- sets `selected = Some(0)` whenever the candidate list is non-empty, -- and `resolve_accepted_value` returns the CANDIDATE whenever -- anything is selected. So `on_accept` receives typed text only when -- the input filters every candidate away --- which, since candidates -- are basenames and the filter is a subsequence match, is exactly -- when the input contains a `/`. That makes the deeper-path case work -- (`sub/inner.txt` matches no basename, so it arrives verbatim) and -- leaves TWO documented consequences, each pinned by a test rather -- than left to be rediscovered: -- -- (a) typing a NEW bare name that happens to be a subsequence of an -- existing entry opens the existing file instead of creating the -- new one --- `find_file_selected_candidate_shadows_typed_text`. -- A new bare name that matches nothing is unaffected and creates -- normally (`find_file_bare_new_name_creates_in_the_root`). -- (b) accepting on EMPTY input opens the first candidate in sort -- order. `fuzzy_score` returns `Some(0)` for an empty needle, so -- everything ties and `filter_and_sort` falls back to -- lexicographic order --- which puts dotfiles first, and can put -- a DIRECTORY first, in which case the open fails and reports. -- This is the same mechanism `M-x` and `switch-buffer` already -- have, so it is inherited rather than introduced; it is recorded -- as decided, not overlooked, and listed in the framing's -- deferrals beside the accept-semantics fix that would close it. -- -- The root is the active buffer's directory, or the process cwd when the -- buffer has no backing path (`source_root` defaults to "." Rust-side, -- so the nil case needs no special handling here). It appears in the -- prompt because the field itself must stay empty: any prefill would -- contain a `/` and filter every candidate away, killing completion. -- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil. local function find_file_dirname(path) local dir = path:match("^(.*)/[^/]*$") if dir == nil then return nil end if dir == "" then return "/" end return dir end -- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` -> -- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's -- own `expand_tilde`. -- -- This has to happen HERE, before the path reaches the core, because -- `get_or_load_buffer` normalizes the path it STORES but loads from the -- raw one --- so a `~/...` path deduplicates against an already-open -- buffer yet fails to load when the file is not open yet. Expanding up -- front makes both halves agree. local function find_file_expand_tilde(path) local home = os.getenv("HOME") if home == nil or home == "" then return path end if home:sub(-1) == "/" then home = home:sub(1, -2) end if path == "~" then return home end local rest = path:match("^~/(.*)$") if rest == nil then return path end return home .. "/" .. rest end -- Turn an accepted value into a path. The value is either a bare -- basename (a selected candidate) or whatever the user typed, so a -- non-absolute value joins onto the prompt's root --- which resolves -- both cases to the same file when they name the same one. local function find_file_resolve(root, value) local path = find_file_expand_tilde(value) if path:sub(1, 1) == "/" then return path end local base = root or "." if base:sub(-1) == "/" then return base .. path end return base .. "/" .. path end -- The active buffer's directory, or nil when it has no backing path. local function find_file_root() local buf = pmacs.window.buffer() if buf == nil then return nil end local ok, path = pcall(function() return buf:path() end) if not (ok and path) then return nil end return find_file_dirname(path) end cmd { name = "find-file", description = "Open a file by path, completing within one directory.", fn = function() local root = find_file_root() pmacs.minibuffer.read { prompt = "Find file (" .. (root or ".") .. "): ", source = "files", source_root = root, history = "find-file", on_accept = function(value) if value == nil or value == "" then return end local path = find_file_resolve(root, value) -- A path that does not exist yet CREATES a buffer bound to -- it: `display_file` routes through `resolve_target_buffer`, -- which on NotFound creates, binds, and sets "[new file]". -- That is Emacs parity and deliberate, so only a real -- failure (a directory, a permission error) reaches here. local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then pmacs.editor.set_status("find-file: " .. tostring(err)) end end, } end } -- Command palette (M-x) ------------------------------------------------------ -- -- Opens the minibuffer with a "commands" completion source, then -- invokes whichever command the user accepts. History is bucketed -- under "command" so M-x recalls recent picks. cmd { name = "editor.execute-command", description = "Run a named command via the minibuffer (M-x).", fn = function() pmacs.minibuffer.read { prompt = "M-x ", source = "commands", history = "command", on_accept = function(name) if name == nil or name == "" then return end -- invoke_interactive records the command boundary (kill -- ring Q#KR2), so chain-sensitive commands behave as under -- Emacs's execute-extended-command: M-x kill-line then C-k -- appends; C-k then M-x kill-line does not. local ok, err = pcall(pmacs.command.invoke_interactive, name) if not ok then -- mlua's `tostring(err)` includes a Lua stack traceback -- separated by newlines. The status line is one row; -- show just the first (informative) line so the -- traceback doesn't leak into the terminal grid. local msg = tostring(err) local first = msg:match("^[^\n]*") or msg pmacs.editor.set_status("M-x error: " .. first) end end, } end } -- Workers and project search (T M3.6, T M3.7; grep-mode upgrade per -- docs/compile-mode-framing.md Q#CM7) --------------------------------------- -- -- `pmacs.workers.grep` is the runtime API; the user-facing entry -- point is `M-x project.search`, which prompts for a query and -- streams matches into a `*search-results*` buffer. Subsequent -- searches cancel any in-flight predecessor through the -- `supersede = "search"` key (M3.6 acceptance: cancel within 50 ms). -- `pmacs.project.search(query, opts)` is the same logic without -- the prompt --- callable from Lua scripts and tests. -- -- The panel is a first-class locations buffer: read-only intercept -- with bypass writes, RET visits, n/p walk match lines, q restores, -- undo chords are no-ops, and each run claims the unified -- next-error source so M-g n walks matches. The worker's matches -- are already structured — no regex parsing. Every producer write -- runs the revision check BEFORE mutating so a no-hook external -- edit cannot be masked by the next batch advancing the expected -- revision. pmacs.project = pmacs.project or {} local SEARCH_RESULTS_NAME = "*search-results*" local GREP_DESYNC_MARKER = "\n[output desynced by external edit]\n" local active_search_id = nil local active_search_stream = nil -- Panel state: buffer incarnation, the root this search ran with -- (retained across interactive supersedes issued from inside the -- pathless panel), match locations, and the revision guard. local search_panel = nil local function grep_count_newlines(s) local n = 0 local i = 0 while true do i = s:find("\n", i + 1, true) if not i then return n end n = n + 1 end end local function search_panel_alive() return search_panel ~= nil and search_panel.buf ~= nil and search_panel.buf:is_valid() end -- Resync after an external edit (the Q#CM2 discipline, grep shape): -- drop row anchors (a revision carries no edit range), append the -- marker, and recompute the row epoch. The match-location list -- survives for M-g n. local function search_panel_resync() local p = search_panel for _, m in ipairs(p.matches) do m.row = nil end local buf = p.buf buf:insert(buf:len(), GREP_DESYNC_MARKER, { bypass_intercept = true }) p.next_row = grep_count_newlines(buf:slice(0, buf:len())) p.expected_rev = buf:revision() end local function search_panel_check_rev() if not search_panel_alive() then return false end local p = search_panel if p.expected_rev ~= nil and p.buf:revision() ~= p.expected_rev then search_panel_resync() end return true end -- Revision-checked producer append: check BEFORE the write (so a -- mismatch is marked rather than masked), record after. Returns the -- row the text landed on, or nil when the panel is gone. local function search_panel_append(text) if not search_panel_check_rev() then return nil end local p = search_panel local row = p.next_row local buf = p.buf buf:insert(buf:len(), text, { bypass_intercept = true }) p.expected_rev = buf:revision() p.next_row = row + grep_count_newlines(text) return row end local SEARCH_UNDO_CHORDS = { "C-/", "C-_", "C-4", "C-x u", "C-?", "C-S-_", "C-x r" } local function ensure_search_panel() if search_panel_alive() then return search_panel end local p = search_panel or { matches = {}, match_index = 0 } search_panel = p local buf for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == SEARCH_RESULTS_NAME then buf = id break end end p.buf = buf or pmacs.buffer.create(SEARCH_RESULTS_NAME) pmacs.buffer.add_intercept(p.buf, function() error(SEARCH_RESULTS_NAME .. " is read-only") end) pmacs.buffer.set_round_trip_input(p.buf, true) -- Kill-mid-search safety (Q#CM7): cancel the stream and -- invalidate the id so late callbacks drop instead of writing -- through a stale handle; the next search recreates the buffer. pcall(pmacs.buffer.on_removed, p.buf, function() if active_search_stream then pcall(function() active_search_stream:cancel() end) end active_search_id = nil active_search_stream = nil p.buf = nil end) local function bind(seq, command) pmacs.keymap.bind { scope = "buffer", buffer = p.buf, sequence = seq, command = command, } end bind("RET", "project-search.visit") bind("n", "project-search.next-line") bind("p", "project-search.previous-line") bind("q", "project-search.quit") for _, seq in ipairs(SEARCH_UNDO_CHORDS) do bind(seq, "compile.undo-noop") end return p end -- Immediate command-path recovery for the panel (the Q#CM2 trigger -- the compile slots already have): M-x/menu edits fire -- buffer.after-edit, and after a COMPLETED search no producer write -- or navigation may ever come — without this, an M-x buffer.undo -- left corrupted output unmarked indefinitely (PR #113 round-1 -- finding 2). Hook edits don't re-fire the hook, so the resync -- marker can be appended from here safely. pmacs.hook.add("buffer.after-edit", function() local cur = pmacs.window.buffer() if cur and search_panel_alive() and cur == search_panel.buf then search_panel_check_rev() end end) -- Cursor walk via primitives (the lsp.lua visit idiom; 0-based). -- Movement-bounded like compile.lua's walk: a match pointing past -- EOF/EOL clamps instead of looping (the file may have changed on -- disk since the worker scanned it). local function search_move_cursor_to(line, col) pmacs.editor.move_line_start() while pmacs.editor.cursor_line() > 0 do pmacs.editor.move_up() end for _ = 1, line do local before = pmacs.editor.cursor_line() pmacs.editor.move_down() if pmacs.editor.cursor_line() == before then break end end local row = pmacs.editor.cursor_line() for _ = 1, col do local before = pmacs.editor.cursor() pmacs.editor.move_right() if pmacs.editor.cursor() == before then break end if pmacs.editor.cursor_line() ~= row then pmacs.editor.move_left() break end end end local function visit_match(idx) local p = search_panel local m = p and p.matches[idx] if not m then return end -- Worker match paths are relative to the search root, not the -- cwd — resolve against the root this search ran with. local path = m.file if path:sub(1, 1) ~= "/" then local root = p.root or "." if root:sub(-1) ~= "/" then root = root .. "/" end path = root .. path end pmacs.editor.push_jump() local ok, err = pcall(pmacs.buffer.find_or_open, path) if not ok then pmacs.editor.jump_back() pmacs.editor.set_status("search: failed to open " .. path .. ": " .. tostring(err)) return end search_move_cursor_to(m.line, m.col) p.match_index = idx end -- Root resolution (Q#CM7): explicit opt > the panel's stored root -- when searching from inside the pathless panel (the natural -- supersede path would otherwise silently degrade to ".") > the -- active file's project root > ".". local function resolve_search_root(opts) if opts.root then return opts.root end local cur = pmacs.window.buffer() if cur and search_panel_alive() and cur == search_panel.buf and search_panel.root then return search_panel.root end if cur then local okp, path = pcall(function() return cur:path() end) if okp and path then local okd, proj = pcall(pmacs.project.detect, path) if okd and proj and proj.root then return proj.root end end end return "." end function pmacs.project.search(query, opts) if type(query) ~= "string" or query == "" then return nil end opts = opts or {} local root = resolve_search_root(opts) local p = ensure_search_panel() -- q-target discipline: never capture a generated buffer. local cur = pmacs.window.buffer() if cur and not (pmacs.compile and pmacs.compile.is_generated_buffer and pmacs.compile.is_generated_buffer(cur)) then p.prev = cur end p.root = root p.matches = {} p.match_index = 0 -- Replace any prior contents in one shot, then append a header. -- The buffer is reused across searches; clearing here is what -- gives the user a fresh page per query. local buf = p.buf if buf:len() > 0 then buf:delete(0, buf:len(), { bypass_intercept = true }) end local header = "Searching for: " .. query .. "\n\n" buf:insert(0, header, { bypass_intercept = true }) p.next_row = grep_count_newlines(header) p.expected_rev = buf:revision() pmacs.window.switch_buffer(buf) local stream = pmacs.workers.grep( { root = root, pattern = query }, { supersede = "search" }) active_search_id = stream:id() active_search_stream = stream -- Claim the unified next-error source (Q#CM5): M-g n walks the -- match list, which survives desync epochs. Guarded: this chunk -- loads before the runtime chunks, and a minimal harness may -- invoke search without compile.lua installed. local claim = pmacs.errors and pmacs.errors.claim or function() end claim { name = "grep", next = function() if not p.matches or #p.matches == 0 then pmacs.editor.set_status("search: no matches") return end if p.match_index >= #p.matches then pmacs.editor.set_status("no more errors") return end visit_match(p.match_index + 1) end, previous = function() if p.match_index <= 1 then pmacs.editor.set_status("no more errors") return end visit_match(p.match_index - 1) end, } -- Capture the id at registration time so callbacks for a -- superseded predecessor (whose worker hasn't yet observed -- cancel) drop their late batches instead of polluting the -- successor's results. local my_id = active_search_id stream:on_batch(function(items) if my_id ~= active_search_id then return end if not search_panel_alive() then return end for _, m in ipairs(items) do local row = search_panel_append(string.format("%s:%d:%d: %s\n", m.file, m.line, m.match_start, m.text)) if row then -- Grep normalization (Q#CM7): line is 1-based → minus one; -- match_start is already a 0-based byte offset in the line. p.matches[#p.matches + 1] = { file = m.file, line = m.line - 1, col = m.match_start, row = row, } end end end) stream:on_close(function(status, _value) if my_id ~= active_search_id then return end if not search_panel_alive() then return end search_panel_append(string.format("\n-- search %s --\n", status)) end) return stream end local function match_on_row(row) local p = search_panel if not p then return nil end for i, m in ipairs(p.matches) do if m.row == row then return i end end return nil end local function active_is_search_panel() local cur = pmacs.window.buffer() return cur ~= nil and search_panel_alive() and cur == search_panel.buf end cmd { name = "project-search.visit", description = "Visit the match on the current line of *search-results*.", fn = function() if not active_is_search_panel() then return end if not search_panel_check_rev() then return end local idx = match_on_row(pmacs.editor.cursor_line()) if not idx then pmacs.editor.set_status("no match on this line") return end visit_match(idx) end } local function search_step_line(direction) if not active_is_search_panel() then return end if not search_panel_check_rev() then return end local from = pmacs.editor.cursor_line() local best = nil for _, m in ipairs(search_panel.matches) do if m.row then if direction > 0 and m.row > from and (not best or m.row < best) then best = m.row elseif direction < 0 and m.row < from and (not best or m.row > best) then best = m.row end end end if not best then pmacs.editor.set_status("no more errors") return end local cur = from while cur < best do pmacs.editor.move_down() cur = cur + 1 end while cur > best do pmacs.editor.move_up() cur = cur - 1 end pmacs.editor.move_line_start() end cmd { name = "project-search.next-line", description = "Move to the next match line within *search-results*.", fn = function() search_step_line(1) end } cmd { name = "project-search.previous-line", description = "Move to the previous match line within *search-results*.", fn = function() search_step_line(-1) end } cmd { name = "project-search.quit", description = "Leave *search-results*, restoring the previous buffer.", fn = function() if not active_is_search_panel() then return end local target = search_panel.prev if not (target and target:is_valid()) then for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*scratch*" then target = id break end end target = target or pmacs.buffer.create("*scratch*") end pmacs.window.switch_buffer(target) end } cmd { name = "project.search", description = "Parallel grep across the project; new queries cancel the predecessor.", fn = function() pmacs.minibuffer.read { prompt = "Search: ", history = "search", on_accept = function(query) if query == nil or query == "" then return end pmacs.project.search(query) end, } end } cmd { name = "editor.list-workers", description = "Open the *workers* observability buffer in the active window.", fn = function() pmacs.window.switch_buffer(pmacs.workers.show()) end } -- T M5.6f: describe-instance ------------------------------------------------ -- -- Two commands by design (pmacs has no prefix-arg system in v0.1): -- * `editor.describe-instance` echoes one line to the status row. -- * `editor.describe-instance-buffer` opens *pmacs-instance* with detail. -- -- Both work whether or not an outbound attachment is in scope. In v0.1 -- Local mode the attachment is always nil (the process is its own -- instance) so the output describes the running process self. cmd { name = "editor.describe-instance", description = "Echo a one-line summary of this pmacs instance to the status row.", fn = function() pmacs.editor.set_status(pmacs.instance.echo_line()) end } -- Track the instance buffer id so we only rebind `q` once per -- incarnation (mirrors the workers buffer's C-c C-k rebind pattern). local instance_buffer_id = nil cmd { name = "editor.describe-instance-buffer", description = "Open the *pmacs-instance* detail buffer in the active window.", fn = function() local id = pmacs.instance.show() if instance_buffer_id ~= id then pcall(function() pmacs.keymap.unbind { scope = "buffer", buffer = id, sequence = "q" } end) pmacs.keymap.bind { scope = "buffer", buffer = id, sequence = "q", command = "buffer.kill-this", } instance_buffer_id = id end pmacs.window.switch_buffer(id) end } cmd { name = "buffer.kill-this", description = "Kill the buffer shown in the active window.", fn = function() local id = pmacs.window.buffer() if id ~= nil then pmacs.buffer.kill(id) end end } -- describe-command (M9.6 acceptance lever) --------------------------------- -- -- M9.6's third acceptance bullet ("describe-command reports the -- tool's schema as the documentation") needs a user-callable entry -- point — `pmacs.describe.command(name)` returns the table at the -- Rust layer, but without this M-x command there's no way to reach -- it interactively. Modeled on `editor.describe-instance-buffer`: -- a single `*help*` buffer reused across invocations, with a -- buffer-local `q` → `buffer.kill-this` for dismissal. local HELP_BUFFER_NAME = "*help*" local help_buffer_id = nil local function find_or_create_help_buffer() for _, id in ipairs(pmacs.buffer.list()) do local d = pmacs.describe.buffer(id) if d ~= nil and d.name == HELP_BUFFER_NAME then return id, false end end return pmacs.buffer.create(HELP_BUFFER_NAME), true end local function show_help_text(text) local buf, fresh = find_or_create_help_buffer() -- `buf:delete(0, len)` matches `editor.list-buffers` render_list — we -- replace contents wholesale rather than diffing, since *help* is -- always reflowed for the new subject. local len = buf:len() if len > 0 then buf:delete(0, len) end if #text > 0 then buf:insert(0, text) end if fresh or help_buffer_id ~= buf then pcall(function() pmacs.keymap.unbind { scope = "buffer", buffer = buf, sequence = "q" } end) pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = "q", command = "buffer.kill-this", } help_buffer_id = buf end pmacs.window.switch_buffer(buf) end cmd { name = "editor.describe-command", description = "Prompt for a command name and render its description in *help*.", fn = function() pmacs.minibuffer.read { prompt = "Describe command: ", source = "commands", history = "command", on_accept = function(name) if name == nil or name == "" then return end local info = pmacs.describe.command(name) if info == nil then pmacs.editor.set_status("describe-command: no such command: " .. name) return end local lines = { name, "" } local desc = info.description if type(desc) ~= "string" or desc == "" then desc = "(no description)" end lines[#lines + 1] = desc local key_bindings = info.key_bindings if type(key_bindings) == "table" and #key_bindings > 0 then lines[#lines + 1] = "" lines[#lines + 1] = "Bindings:" for _, b in ipairs(key_bindings) do local seq = (type(b) == "table" and b.sequence) or tostring(b) lines[#lines + 1] = " " .. tostring(seq) end end show_help_text(table.concat(lines, "\n")) end, } end } -- describe-setting (config registry, acceptance 33) ------------------------- -- -- The configuration registry's discovery surface. `pmacs.config.describe` -- returns the metadata table at the Rust layer; this is the interactive -- way in, modeled on `editor.describe-command` directly above and sharing -- its `*help*` buffer handling. -- -- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a -- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a -- settings source means touching the minibuffer candidate machinery, -- which this arc deliberately stays out of. `pmacs.config.list()` is the -- programmatic way to enumerate names meanwhile; a completion source (and -- an M-x list-settings panel) are named deferrals in the framing. local function describe_setting_lines(name, info) -- Header block mirrors help.rs's `format_hook_text`: aligned label -- column, then a blank line, then the description as prose. local lines = { "Setting: " .. name, " Type: " .. tostring(info.type), " Default: " .. tostring(info.default), " Value: " .. tostring(info.value), " Mutability: " .. tostring(info.mutability), " Source: " .. tostring(info.source), } if info.min ~= nil then lines[#lines + 1] = " Min: " .. tostring(info.min) end if info.max ~= nil then lines[#lines + 1] = " Max: " .. tostring(info.max) end if type(info.choices) == "table" and #info.choices > 0 then lines[#lines + 1] = " Choices: " .. table.concat(info.choices, ", ") end lines[#lines + 1] = "" local desc = info.description if type(desc) ~= "string" or desc == "" then desc = "(no description)" end lines[#lines + 1] = desc lines[#lines + 1] = "" -- Overrides. `global` is the global-chain resolution regardless of the -- buffer argument, so "same as default" is a real, distinguishable -- state from "overridden to the same value" only via is_set — which is -- why this reports the resolved values rather than claiming presence. lines[#lines + 1] = "Global value: " .. tostring(info.global) if info.buffer_local ~= nil then lines[#lines + 1] = "Buffer-local override: " .. tostring(info.buffer_local) end return lines end cmd { name = "editor.describe-setting", description = "Prompt for a setting name and render its definition in *help*.", fn = function() pmacs.minibuffer.read { prompt = "Describe setting: ", history = "command", on_accept = function(name) if name == nil or name == "" then return end -- An undefined name raises NotFound rather than returning nil -- (the define-before-set posture, Q#CR10), so this must pcall. local buf = pmacs.window.buffer() local ok, info = pcall(pmacs.config.describe, name, buf) if not ok or type(info) ~= "table" then pmacs.editor.set_status("describe-setting: no such setting: " .. name) return end show_help_text(table.concat(describe_setting_lines(name, info), "\n")) end, } end }