Merge remote-tracking branch 'githubsucks/main' into HEAD

This commit is contained in:
Levi Neuwirth 2026-07-25 14:26:21 -04:00
commit 3952db9657
9 changed files with 3372 additions and 13 deletions

View File

@ -1,11 +1,15 @@
# pmacs agent instructions
**Start here: read `docs/agent-handoff.md`, then
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
`docs/active-work.md`, before taking on any work.** The handoff carries
durable project state, working method, substrate invariants, and the
standing backlog. The active-work ledger carries volatile branches,
standing backlog. `COHERENCE.md` carries the product-coherence thesis
and its audited ground truth (scorecard, per-concern gaps, priority
order) — it is the standard new work gets evaluated against, not just a
backlog item; read it before framing anything and cite the section a
framing doc serves. The active-work ledger carries volatile branches,
checkpoints, verification, and exact cross-machine recovery commands.
Keep both updated according to their own update protocols.
Keep all three updated according to their own update protocols.
Always true, independent of the handoff:
@ -14,7 +18,11 @@ Always true, independent of the handoff:
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
-> full gate suite -> PR -> user review rounds -> user says when to
merge. Never merge unprompted. One feature, one branch, one PR.
merge. Never merge unprompted. One feature, one branch, one PR. A
framing doc for coherence-affecting work should state its coherence
impact (journey steps touched, interaction islands added, config
registry adoption, background-work attribution) per `COHERENCE.md`
§20.
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
`cargo test --lib --features crdt`; the touched acceptance suites;

View File

@ -1,11 +1,15 @@
# pmacs agent instructions
**Start here: read `docs/agent-handoff.md`, then
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
`docs/active-work.md`, before taking on any work.** The handoff carries
durable project state, working method, substrate invariants, and the
standing backlog. The active-work ledger carries volatile branches,
standing backlog. `COHERENCE.md` carries the product-coherence thesis
and its audited ground truth (scorecard, per-concern gaps, priority
order) — it is the standard new work gets evaluated against, not just a
backlog item; read it before framing anything and cite the section a
framing doc serves. The active-work ledger carries volatile branches,
checkpoints, verification, and exact cross-machine recovery commands.
Keep both updated according to their own update protocols.
Keep all three updated according to their own update protocols.
Always true, independent of the handoff:
@ -14,7 +18,11 @@ Always true, independent of the handoff:
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
-> full gate suite -> PR -> user review rounds -> user says when to
merge. Never merge unprompted. One feature, one branch, one PR.
merge. Never merge unprompted. One feature, one branch, one PR. A
framing doc for coherence-affecting work should state its coherence
impact (journey steps touched, interaction islands added, config
registry adoption, background-work attribution) per `COHERENCE.md`
§20.
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
`cargo test --lib --features crdt`; the touched acceptance suites;

1547
COHERENCE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -611,6 +611,129 @@ 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 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

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

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-24.** This file records volatile work that has not
**Snapshot: 2026-07-25.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
@ -188,6 +188,45 @@ If it does not, stop and repair the remote/fetch configuration.
suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
- Approved framing: `docs/dired-framing.md` (revision 5), landing as its
own docs PR off `githubsucks/main` @ `2af1ab3`, branch
`githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The
repo's `-framing`-branch convention (`vterm-framing`,
`gpu-initial-target-framing`, `tab-width-parity-framing`).
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
2026-07-25, one review round, 12/12 CI green). Durable facts moved to
`docs/agent-handoff.md` §1 per rule 3 below.
- **Stage 1 (the dired view) is next and unstarted.** Branch `dired`
(worktree `../pmacs-dired-arc`) carries the framing commits only and is
based on the now-superseded `0827dd1`; **rebase it onto the `main`
resulting from the framing PR before implementing**, or cut a fresh
branch — its framing commits become redundant once the docs PR lands.
- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the
`dired` major mode + mode keymap; buffer-per-directory with lexical
canonicalization and the ownership check; read-only intercept +
`set_round_trip_input`; visit routing through `window.display_file`;
parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`;
cursor preservation by basename; the `dired.kill-when-opening` config
key; **and the tolerant `read_dir` opt** — the only Rust in the stage.
- The one Rust change is load-bearing and is why Stage 1 is not
pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire
listing** on any of five per-entry conditions, and the tolerant wrapper
its own module doc delegates to package authors **cannot be written in
Lua** — the primitive returns one error and no partial vec.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md`
§20 Priority 1, which names this work explicitly; journey steps 7 and
(partially) 3; **adds no interaction island** — keys are a mode-scoped
keymap, and wdired is a mode swap; adopts `pmacs.config` for
`dired.kill-when-opening`; inherits §9's worker-attribution gap for its
`read_dir` jobs without worsening it.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here. The two meet at `resolve_target_buffer`; dired supplies the
buffer a directory should resolve *to*, and `pmacs .` should route into
it rather than growing a second directory surface.
## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW
- Portable branch: `githubsucks/bottom-panel`, worktree

View File

@ -1,7 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-24, after GPU initial-target (#148, protocol v20)
landed, following folding Stage 2 (#149) and its landed-doc refresh (#150),
**Last updated: 2026-07-25, after find-file (#162) landed — the dired
arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
GPU initial-target doc refresh (#152); and before that GPU
initial-target (#148, protocol v20),
following folding Stage 2 (#149) and its landed-doc refresh (#150),
web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair
(#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the
documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering
@ -19,9 +24,55 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-24)
## 1. Where the project stands (2026-07-25)
- `main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2 landed-doc
- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1
#160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math
re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol
unchanged at **v20**. The bullets below describe the arcs in their own
terms; this line is the head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
standard new work is evaluated against. Per `CLAUDE.md`, **every new
framing doc must state its coherence impact** — journey steps touched,
interaction islands added, config-registry adoption, background-work
attribution. Its §2 grades the golden journey **broken at step 3**
(`pmacs .` exits 1).
- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge
`2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**:
pmacs previously had no discoverable way to open a file by path — no
such command existed and `pmacs.buffer.find_or_open` had no interactive
caller. Pure Lua in `builtin/commands/default.lua`, one keymap line, an
8-test dispatch-driven acceptance suite; no Rust, no protocol change.
Two substrate facts it documents, both worth knowing before touching
any minibuffer prompt:
- **Completion over files is flat and cannot be made hierarchical from
Lua.** A custom `source` function is called with **zero arguments**
(`minibuffer.rs:591`) and runs synchronously outside any coroutine,
where `Handle:await()` raises — so it can neither see the input to
re-root on nor list a directory. Only the Rust
`CompletionSource::Files { root }` can list, and it is
single-directory and 1024-capped.
- **A selected candidate SHADOWS typed text.** `recompute_candidates`
sets `selected = Some(0)` whenever the list is non-empty
(`minibuffer.rs:372-377`) and `resolve_accepted_value` returns the
candidate over the typed contents (`:564-574`). So free-text accept
fires only when the input filters every candidate away — for
basename candidates under a subsequence filter, when it contains a
`/`. This applies to `M-x` and `switch-buffer` too. Consequences are
pinned as decisions, including the hole where a new bare name that is
a subsequence of an existing entry opens the existing file, and the
empty-input case (`fuzzy_score` gives `Some(0)` for an empty needle
and ties break lexicographically, so dotfiles lead).
- Also: `get_or_load_buffer` computes a normalized path but **loads
from the raw one** (`editor_core.rs:842-856`), so a `~/…` path dedups
against an open buffer yet fails to load one that is not open —
find-file expands the tilde Lua-side. Loading through the normalized
path is a named deferral.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
refresh #150, folding Stage 2 #149, ledger refresh #147, web grammars #146,
LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142),
protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =

1209
docs/dired-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,373 @@
// 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:?}"
);
}
/// The everyday new-file flow: a BARE name, no separator, matching no
/// existing entry. The candidate list empties on its own, so the typed
/// text arrives and joins onto the root. This is the path users hit
/// first, and it is the only route through `find_file_resolve` that
/// combines free text with a relative join.
#[test]
fn find_file_bare_new_name_creates_in_the_root() {
let td = tempfile::tempdir().expect("tempdir");
let fresh = td.path().join("zzz.txt");
let mut s = editor_in(td.path());
open_prompt(&mut s);
// "zzz.txt" is not a subsequence of "anchor.txt" (no 'z' in it), so
// nothing survives the filter and the typed name is what accepts.
type_str(&mut s, "zzz.txt");
assert!(
candidates(&s).is_empty(),
"fixture premise: a bare non-matching name must empty the list; got {:?}",
candidates(&s)
);
press(&mut s, KeyCode::Enter);
let path = active_path(&s).expect("a buffer must be bound to the new path");
assert_eq!(
std::path::Path::new(&path).parent(),
Some(td.path()),
"a bare name must join onto the prompt's root; got {path}"
);
assert!(
path.ends_with("zzz.txt"),
"the buffer must carry the typed name; 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(), "nothing is written to disk until save");
}
/// The failure arm. Accepting a DIRECTORY candidate reaches
/// `display_file`, whose load fails (opening a directory succeeds, the
/// read does not), and the command's `pcall` must turn that into a
/// status message rather than letting the error escape mid-dispatch.
/// Without the `pcall` this test fails, which is the point --- the
/// guard is pinned through the real accept path, not asserted directly.
#[test]
fn find_file_accepting_a_directory_reports_instead_of_raising() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
let mut s = editor_in(td.path());
let before = active_path(&s).expect("the anchor must be open");
open_prompt(&mut s);
// Only the directory matches: "anchor.txt" contains no 's'.
type_str(&mut s, "sub");
assert_eq!(
candidates(&s),
vec!["sub".to_string()],
"fixture premise: the directory must be the sole candidate"
);
press(&mut s, KeyCode::Enter);
let line = status(&s);
assert!(
line.starts_with("find-file: "),
"the failure must surface as this command's status message; got {line:?}"
);
assert_eq!(
active_path(&s).as_deref(),
Some(before.as_str()),
"a failed open must leave the active buffer alone"
);
assert!(
!eval::<bool>(&s, "return pmacs.minibuffer.is_active()"),
"the prompt must have closed even though the open failed"
);
}
/// 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}"
);
}