feat(find-file): open a file by path with C-x C-f

Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now
pmacs had no discoverable way to open a file by path: no find-file
command and no C-x C-f binding, so 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.

The command prompts with completion rooted at the active buffer's
directory, or the process cwd when the buffer has no backing path, and
opens the result through pmacs.window.display_file. A path that does not
exist yet creates a buffer bound to it with the "[new file]" status,
which is Emacs parity and comes from resolve_target_buffer rather than
anything added here. Nothing is written to disk until the user saves.

Two substrate facts shape the design and are documented at the command
rather than left to be rediscovered.

Completion is flat: the files source lists one directory and yields bare
basenames, and a custom function source could not do better, because
sources are called with no arguments and run synchronously outside any
coroutine, so a callback can neither see the input to re-root on nor
await a directory listing. Hierarchical completion is a named Rust
change in the framing.

A selected candidate shadows typed text: recompute_candidates selects
index 0 whenever the candidate list is non-empty, and
resolve_accepted_value returns the candidate over the typed contents. So
typed text reaches the accept handler exactly when the input filters
every candidate away, which for basename candidates under a subsequence
filter means when it contains a separator. That makes the deeper-path
case work verbatim and leaves one hole: a new bare name that is a
subsequence of an existing entry opens the existing file. The acceptance
pins that as a decision rather than an accident; closing it needs a Rust
change to accept semantics that Stage 0 deliberately does not make.

A leading tilde is expanded before the path reaches the core, because
get_or_load_buffer normalizes the path it stores but loads from the raw
one -- so an unexpanded tilde path deduplicates against an already-open
buffer yet fails to load a file that is not open yet.

The prompt field starts empty and names its root in the prompt string
instead: any prefill would contain a separator and silently disable
completion.

Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing,
a real RET -- so a dead binding cannot pass vacuously and the Lua
lifecycle accept(), which bypasses the path interactive input takes, is
not used.
This commit is contained in:
Levi Neuwirth 2026-07-25 10:45:50 -04:00
parent 0827dd1416
commit 2a0884b377
3 changed files with 402 additions and 0 deletions

View File

@ -611,6 +611,116 @@ cmd { name = "editor.switch-buffer",
}
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 one documented hole: 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. `acc4` pins that as a known
-- behavior rather than letting it be an accident.
--
-- 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

View File

@ -148,6 +148,7 @@ bind("C-x o", "window.focus-next")
bind("C-x O", "window.focus-prev")
bind("C-x 0", "window.close")
bind("C-x 1", "window.close-others")
bind("C-x C-f", "find-file")
bind("C-x b", "editor.switch-buffer")
bind("C-x C-b", "editor.list-buffers")
bind("C-x <right>", "editor.next-buffer")

View File

@ -0,0 +1,291 @@
// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance.
//! Acceptance for `find-file`, the dired arc's Stage 0
//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11).
//!
//! Dispatch-driven throughout: the prompt is opened with a real
//! `C-x C-f`, filled by typing real keys, and completed with a real
//! RET. `pmacs.command.invoke` would bypass the binding (a dead
//! keymap entry would pass vacuously) and the Lua lifecycle
//! `minibuffer.accept()` bypasses the dispatch path interactive input
//! actually takes --- the editops suite's discipline, for the same
//! reasons.
//!
//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a
//! language server.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
/// Open the find-file prompt through the real `C-x C-f` binding.
fn open_prompt(s: &mut EditorState) {
ctrl(s, 'x');
ctrl(s, 'f');
assert!(
eval::<bool>(s, "return pmacs.minibuffer.is_active()"),
"C-x C-f must open a minibuffer prompt"
);
}
/// The active buffer's backing path, or `None`.
fn active_path(s: &EditorState) -> Option<String> {
eval::<Option<String>>(
s,
"local b = pmacs.window.buffer()\n\
if b == nil then return nil end\n\
local ok, p = pcall(function() return b:path() end)\n\
if ok then return p end\n\
return nil",
)
}
fn candidates(s: &EditorState) -> Vec<String> {
eval::<Vec<String>>(s, "return pmacs.minibuffer.candidates()")
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// An editor whose active buffer is a real file inside `dir`, so
/// find-file's root resolves to that directory.
fn editor_in(dir: &std::path::Path) -> EditorState {
let anchor = dir.join("anchor.txt");
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
let state = EditorState::new();
state.lua_host.reopen_init_phase_for_testing();
let anchor_str = anchor.display().to_string();
exec(&state, &format!("pmacs.buffer.find_or_open({anchor_str:?})"));
state
}
/// 0a --- completion is flat: it offers the root's own entries and
/// never descends into a subdirectory.
#[test]
fn find_file_completion_lists_the_root_only_and_does_not_descend() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("alpha.txt"), b"a").expect("write");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
let cands = candidates(&s);
assert!(
cands.iter().any(|c| c == "alpha.txt"),
"root entry must be offered; got {cands:?}"
);
assert!(
cands.iter().any(|c| c == "sub"),
"the subdirectory itself must be offered; got {cands:?}"
);
assert!(
!cands.iter().any(|c| c == "inner.txt"),
"completion must NOT descend into subdirectories; got {cands:?}"
);
}
/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no
/// bare-basename candidate, so it reaches `on_accept` verbatim and is
/// joined onto the prompt's root.
#[test]
fn find_file_free_text_opens_a_path_below_the_root() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let inner = td.path().join("sub").join("inner.txt");
std::fs::write(&inner, b"deep contents\n").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
type_str(&mut s, "sub/inner.txt");
assert!(
candidates(&s).is_empty(),
"a needle containing '/' must filter every basename candidate away, \
or the selection would shadow the typed text"
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a file must be open");
assert_eq!(
std::fs::canonicalize(&path).expect("canonicalize opened"),
std::fs::canonicalize(&inner).expect("canonicalize fixture"),
"free text must open the deeper path"
);
let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)");
assert_eq!(text, "deep contents", "the file's real contents must load");
}
/// 0c --- a path that does not exist creates a `[new file]` buffer
/// bound to it, rather than erroring. The name contains a `/` so the
/// candidate list is empty and the typed text is what arrives (see
/// `find_file_selected_candidate_shadows_typed_text` for the other
/// half of that rule).
#[test]
fn find_file_nonexistent_path_creates_a_new_file_buffer() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let fresh = td.path().join("sub").join("brand-new.txt");
assert!(!fresh.exists(), "fixture must not exist yet");
let mut s = editor_in(td.path());
open_prompt(&mut s);
type_str(&mut s, "sub/brand-new.txt");
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound to the new path");
assert!(
path.ends_with("sub/brand-new.txt"),
"the buffer must be bound to the typed path; got {path}"
);
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
assert_eq!(len, 0, "a new-file buffer starts empty");
assert!(
!fresh.exists(),
"find-file must not create the file on disk --- only the buffer"
);
let line = status(&s);
assert!(
line.contains("[new file]"),
"the new-file status must surface; got {line:?}"
);
}
/// 0d --- with no backing path, the prompt roots at the process cwd
/// (`source_root` is omitted, and the Rust side defaults to ".").
/// The test crate's cwd is the crate root, so `Cargo.toml` is a
/// stable, real candidate there.
#[test]
fn find_file_without_a_backing_path_roots_at_the_process_cwd() {
let mut s = EditorState::new();
s.lua_host.reopen_init_phase_for_testing();
assert!(
active_path(&s).is_none(),
"the scratch buffer must have no backing path"
);
open_prompt(&mut s);
let cands = candidates(&s);
assert!(
cands.iter().any(|c| c == "Cargo.toml"),
"a pathless buffer must root the prompt at the process cwd; got {cands:?}"
);
// The field must start EMPTY. Any prefill (e.g. Emacs's
// directory-in-the-field) would contain a `/`, which filters every
// basename candidate away and silently disables completion --- the
// reason the root is named in the prompt string instead.
let typed: String = eval(&s, "return pmacs.minibuffer.contents()");
assert_eq!(
typed, "",
"the prompt field must start empty or completion is dead on arrival"
);
}
/// The documented hole in Q#DR11, pinned so it is a decision rather
/// than an accident: `recompute_candidates` selects index 0 whenever
/// the list is non-empty and `resolve_accepted_value` returns the
/// SELECTED CANDIDATE over the typed text, so typing a new bare name
/// that is a subsequence of an existing entry opens the existing file.
/// Fixing this needs a Rust change to accept semantics, which Stage 0
/// deliberately does not make.
#[test]
fn find_file_selected_candidate_shadows_typed_text() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write");
let mut s = editor_in(td.path());
open_prompt(&mut s);
// "nots" is a subsequence of "notes.md", so the candidate survives
// the filter and shadows the typed name.
type_str(&mut s, "nots");
assert_eq!(
candidates(&s),
vec!["notes.md".to_string()],
"the fixture depends on 'nots' matching 'notes.md'"
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a file must be open");
assert!(
path.ends_with("notes.md"),
"documented behavior: the selected candidate wins over typed text; got {path}"
);
}
/// A leading `~` is expanded before the path reaches the core. This
/// matters because `get_or_load_buffer` normalizes the path it STORES
/// but loads from the RAW one, so an unexpanded `~/...` would dedup
/// against an open buffer yet fail to load a file that is not open.
#[test]
fn find_file_expands_a_leading_tilde() {
let Some(home) = std::env::var_os("HOME") else {
eprintln!("HOME unset; skipping tilde expansion pin");
return;
};
let home = home.to_string_lossy().into_owned();
if home.is_empty() || !std::path::Path::new(&home).is_dir() {
eprintln!("HOME is not a usable directory; skipping");
return;
}
let mut s = EditorState::new();
s.lua_host.reopen_init_phase_for_testing();
open_prompt(&mut s);
// Contains a '/', so the typed text reaches on_accept verbatim.
// The leaf does not exist, so this lands on the new-file path and
// touches no disk state.
type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt");
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound");
assert!(
!path.contains('~'),
"the tilde must be expanded, not passed through; got {path}"
);
assert!(
path.starts_with(&home),
"the expansion must use $HOME; got {path} with HOME={home}"
);
}