feat(journey): open a directory, on one path
Journey Stage 1a's core: `pmacs .` opens the directory instead of exiting 1, and local startup stops being a second implementation of path resolution. `EditorState::open` now calls `EditorCore::resolve_target_buffer` -- the primitive whose own doc comment says it exists "so two path-normalization, dedup, and hook transactions cannot drift apart", and which local startup had never been a caller of. `resolve_target_buffer` returns a typed `ResolvedTarget` rather than `(BufferId, HookKind)`, with a `Directory` arm checked ahead of the load. Without it the load runs and fails: `File::open` succeeds on a directory and `read_to_end` returns EISDIR, which is not `NotFound`, so the `[new file]` arm never fired. A directory creates no buffer. It dispatches a resolver chain: the short-circuit `path.open-directory` hook, which no builtin subscribes to, and then `pmacs.path.directory_handler`, which dired defaults. The split is forced rather than chosen -- hook callbacks only append and builtins load before init.lua, so a subscribing builtin would always claim before any user listener could run. A raising listener stops the chain and suppresses the fallback. The listing is async and the daemon bootstrap is not, so the whole post-await commit runs inside a new `pmacs.window.commit_to`: it validates the destination -- frontend live, window live, buffer unchanged, window replaceable -- BEFORE invoking its callback, then scopes the acting frontend for its extent. Validating at display time would be four dired mutations too late. That scope is deliberately not `InteractiveCommandOrigin`, which does not reach the core-ambient APIs and is authenticated user-command authority a background continuation must not acquire. The dedication rule is extracted into one `window_accepts_buffer` shared by exact display, the display probe, and the new preflight, with `incoming: Option<BufferId>` -- `None` means "the replacement does not exist yet" and refuses a dedicated window. `display_file` keeps its directory-is-an-error contract and does not enter the chain; find-file's accept arm depends on it. Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15).
This commit is contained in:
parent
dd9f380533
commit
f09f66ce37
|
|
@ -61,6 +61,21 @@ define {
|
||||||
kind = "all-must-succeed",
|
kind = "all-must-succeed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
define {
|
||||||
|
name = "path.open-directory",
|
||||||
|
description = "Fired when a directory path is opened (Journey Stage 1a). " ..
|
||||||
|
"Receives the canonical absolute path and an opaque " ..
|
||||||
|
"destination. Return false to CLAIM the directory and stop " ..
|
||||||
|
"the fan-out; return nothing to decline. No builtin " ..
|
||||||
|
"subscribes -- because hook callbacks only ever append, a " ..
|
||||||
|
"subscribing builtin would always claim before any user " ..
|
||||||
|
"listener could run, so this hook is the user's chain and " ..
|
||||||
|
"pmacs.path.directory_handler is the default surface it " ..
|
||||||
|
"falls back to. A callback that RAISES stops the chain and " ..
|
||||||
|
"suppresses that fallback.",
|
||||||
|
kind = "short-circuit",
|
||||||
|
}
|
||||||
|
|
||||||
define {
|
define {
|
||||||
name = "editor.before-quit",
|
name = "editor.before-quit",
|
||||||
description = "Fired before the editor exits. Return false to veto.",
|
description = "Fired before the editor exits. Return false to veto.",
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,17 @@ end
|
||||||
-- inside a coroutine spawned by pmacs.async --- a bare call from main
|
-- inside a coroutine spawned by pmacs.async --- a bare call from main
|
||||||
-- thread will raise on the first yield.
|
-- thread will raise on the first yield.
|
||||||
function Handle:await()
|
function Handle:await()
|
||||||
|
-- Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` scopes the
|
||||||
|
-- acting frontend for the dynamic extent of its callback, using an
|
||||||
|
-- RAII guard on the Rust stack. Yielding out of that extent would
|
||||||
|
-- restore the scope while this coroutine is still parked, so the rest
|
||||||
|
-- of the commit would resume ambient -- silently reintroducing the
|
||||||
|
-- misrouting the scope exists to prevent. Do the awaiting BEFORE
|
||||||
|
-- entering the commit, which is what dired does with its listing.
|
||||||
|
if async_mod._in_commit_scope() then
|
||||||
|
error("await: cannot await inside pmacs.window.commit_to; " ..
|
||||||
|
"await first, then commit")
|
||||||
|
end
|
||||||
if not async_mod._is_complete(self._id) then
|
if not async_mod._is_complete(self._id) then
|
||||||
-- Yield self so pmacs.async's step() can park us. R46 carve-out:
|
-- Yield self so pmacs.async's step() can park us. R46 carve-out:
|
||||||
-- this `coroutine.yield` is runtime code; package code uses
|
-- this `coroutine.yield` is runtime code; package code uses
|
||||||
|
|
|
||||||
|
|
@ -598,7 +598,7 @@ end
|
||||||
|
|
||||||
pmacs.dired = pmacs.dired or {}
|
pmacs.dired = pmacs.dired or {}
|
||||||
|
|
||||||
local OPEN_OPTS = { display = true, select_name = true }
|
local OPEN_OPTS = { display = true, select_name = true, dest = true }
|
||||||
|
|
||||||
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
|
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
|
||||||
-- window it occupies when this is a navigation rather than a fresh
|
-- window it occupies when this is a navigation rather than a fresh
|
||||||
|
|
@ -629,36 +629,64 @@ local function open_directory(path, opts, departed)
|
||||||
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
|
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
|
||||||
local entries, errors = read_listing(canonical, sort_mode)
|
local entries, errors = read_listing(canonical, sort_mode)
|
||||||
|
|
||||||
local handle = claim_handle(canonical)
|
-- Everything from here down MUTATES: it claims or finds a handle,
|
||||||
handle.entries = entries
|
-- creates a buffer, reads the ambient buffer for `prev`, and paints.
|
||||||
handle.errors = errors
|
-- None of it is undoable, and none of it may run against a
|
||||||
handle.sort_mode = sort_mode
|
-- destination that has gone away -- so when the caller captured one
|
||||||
|
-- (Journey Stage 1a, Q#JR14), the whole commit runs inside
|
||||||
|
-- `pmacs.window.commit_to`, which validates the destination BEFORE
|
||||||
|
-- invoking this and scopes the acting frontend for its extent.
|
||||||
|
--
|
||||||
|
-- Note the await above is deliberately OUTSIDE the commit: awaiting
|
||||||
|
-- inside it is refused (Q#JR14b), because a yield would restore the
|
||||||
|
-- scope while this coroutine is still parked.
|
||||||
|
local function commit()
|
||||||
|
local handle = claim_handle(canonical)
|
||||||
|
handle.entries = entries
|
||||||
|
handle.errors = errors
|
||||||
|
handle.sort_mode = sort_mode
|
||||||
|
|
||||||
-- `q` returns to the buffer you came from, never to another dired
|
-- `q` returns to the buffer you came from, never to another dired
|
||||||
-- buffer (which would trap `q` walking back down the tree); on a
|
-- buffer (which would trap `q` walking back down the tree); on a
|
||||||
-- descent the arriving buffer inherits the departing one's origin.
|
-- descent the arriving buffer inherits the departing one's origin.
|
||||||
if departed ~= nil then
|
if departed ~= nil then
|
||||||
handle.prev = departed.prev
|
handle.prev = departed.prev
|
||||||
else
|
else
|
||||||
local active = pmacs.window.buffer()
|
local active = pmacs.window.buffer()
|
||||||
if active ~= nil and handle_for_buffer(active) == nil then
|
if active ~= nil and handle_for_buffer(active) == nil then
|
||||||
handle.prev = active
|
handle.prev = active
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
paint(handle)
|
||||||
|
display(handle, opts, departed)
|
||||||
|
-- Seating happens after the display: `switch_buffer` zeroes the
|
||||||
|
-- window cursor, so an earlier seat would be discarded.
|
||||||
|
seat_cursor(handle, opts.select_name, 1)
|
||||||
|
kill_departed(departed, handle)
|
||||||
|
return handle.buf
|
||||||
end
|
end
|
||||||
|
|
||||||
paint(handle)
|
if opts.dest == nil then
|
||||||
display(handle, opts, departed)
|
-- Interactive path (`C-x d`, tree descent, refresh): the acting
|
||||||
-- Seating happens after the display: `switch_buffer` zeroes the
|
-- frontend is still ambient a tick later, which is what dired has
|
||||||
-- window cursor, so an earlier seat would be discarded.
|
-- always relied on. Migrating these onto a captured destination too
|
||||||
seat_cursor(handle, opts.select_name, 1)
|
-- is a named deferral, not this stage's work.
|
||||||
kill_departed(departed, handle)
|
return commit()
|
||||||
return handle.buf
|
end
|
||||||
|
|
||||||
|
local ok, result = pmacs.window.commit_to(opts.dest, commit)
|
||||||
|
if not ok then
|
||||||
|
error(string.format("destination is gone (%s)", tostring(result)))
|
||||||
|
end
|
||||||
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
function pmacs.dired.open(path, opts)
|
function pmacs.dired.open(path, opts)
|
||||||
return open_directory(path, opts, nil)
|
return open_directory(path, opts, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Every interactive entry point funnels through here: spawn the
|
-- Every interactive entry point funnels through here: spawn the
|
||||||
-- coroutine the await needs, and turn a failure into a status message
|
-- coroutine the await needs, and turn a failure into a status message
|
||||||
-- rather than an uncaught raise inside `pmacs.async` (which would land
|
-- rather than an uncaught raise inside `pmacs.async` (which would land
|
||||||
|
|
@ -670,6 +698,20 @@ local function open_async(path, opts, departed, where)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Journey Stage 1a (Q#JR7): dired is the DEFAULT directory surface, not
|
||||||
|
-- a `path.open-directory` subscriber.
|
||||||
|
--
|
||||||
|
-- It cannot be a subscriber and still be replaceable. `HookRegistry.add`
|
||||||
|
-- only appends, and builtins load before `init.lua`, so a dired
|
||||||
|
-- subscription would always run first and always claim -- no user
|
||||||
|
-- listener could ever win. The hook is therefore the user's chain and
|
||||||
|
-- this slot is the fallback the editor consults when that chain
|
||||||
|
-- declines. Replace it to change what opens a directory; set it to nil
|
||||||
|
-- to disable directory opening entirely.
|
||||||
|
pmacs.path.set_directory_handler(function(path, dest)
|
||||||
|
open_async(path, { dest = dest }, nil, "dired")
|
||||||
|
end)
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Commands
|
-- Commands
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1627,18 +1627,57 @@ fn open_initial_target(
|
||||||
// create and select a side window, and bootstrap must reassert the
|
// create and select a side window, and bootstrap must reassert the
|
||||||
// requested buffer in a document window rather than overwriting a
|
// requested buffer in a document window rather than overwriting a
|
||||||
// panel merely because it became `view.active`.
|
// panel merely because it became `view.active`.
|
||||||
let (origin_window, buffer_id, fire) = {
|
let (origin_window, resolved) = {
|
||||||
let mut core = editor.core.borrow_mut();
|
let mut core = editor.core.borrow_mut();
|
||||||
core.active_frontend = frontend_id;
|
core.active_frontend = frontend_id;
|
||||||
let origin_window = core
|
let origin_window = core
|
||||||
.primary_document_window(frontend_id)
|
.primary_document_window(frontend_id)
|
||||||
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
|
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
|
||||||
let (buffer_id, fire) = core.resolve_target_buffer(&path)?;
|
let resolved = core.resolve_target_buffer(&path)?;
|
||||||
|
(origin_window, resolved)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Journey Stage 1a (Q#JR6/Q#JR9): a DIRECTORY installs nothing.
|
||||||
|
//
|
||||||
|
// Nothing can be installed, because the listing that satisfies a
|
||||||
|
// directory open is asynchronous and this block is synchronous — the
|
||||||
|
// frontend is blocked on `InitialTargetResult` and will not create
|
||||||
|
// its window until it arrives, so there is no tick in which a
|
||||||
|
// listing could settle. The reply therefore names the buffer the
|
||||||
|
// fresh view's document window ALREADY holds, which is a valid,
|
||||||
|
// ready session; the listing replaces it a tick or more later.
|
||||||
|
//
|
||||||
|
// That buffer is NOT necessarily `*scratch*`: `build_fresh_frontend_view`
|
||||||
|
// clones LOCAL's primary document buffer. If LOCAL holds a real
|
||||||
|
// document, this session briefly displays and snapshots it. Accepted
|
||||||
|
// and documented rather than papered over with a placeholder buffer,
|
||||||
|
// which would need reaping and would be fought by the reassert below.
|
||||||
|
//
|
||||||
|
// `publish_to_replicas` is false for the same reason an `AfterSwitch`
|
||||||
|
// dedup sets it false: this buffer is pre-existing and already
|
||||||
|
// published, not freshly loaded here.
|
||||||
|
let (buffer_id, fire) = match resolved {
|
||||||
|
crate::editor_core::ResolvedTarget::Directory { path } => {
|
||||||
|
let dest = editor
|
||||||
|
.capture_directory_destination(frontend_id, origin_window)
|
||||||
|
.ok_or_else(|| format!("cannot open {}: no document window", path.display()))?;
|
||||||
|
let buffer_id = dest.buffer;
|
||||||
|
editor.dispatch_directory_open(&path, dest);
|
||||||
|
editor.reconcile_panel_layout(frontend_id);
|
||||||
|
return Ok(OpenedInitialTarget {
|
||||||
|
buffer_id,
|
||||||
|
publish_to_replicas: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut core = editor.core.borrow_mut();
|
||||||
core.install_buffer_in_window(origin_window, buffer_id)
|
core.install_buffer_in_window(origin_window, buffer_id)
|
||||||
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
|
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
|
||||||
core.focus_window(frontend_id, origin_window);
|
core.focus_window(frontend_id, origin_window);
|
||||||
(origin_window, buffer_id, fire)
|
}
|
||||||
};
|
|
||||||
|
|
||||||
match fire {
|
match fire {
|
||||||
crate::editor_core::HookKind::AfterLoad => {
|
crate::editor_core::HookKind::AfterLoad => {
|
||||||
|
|
|
||||||
311
src/editor.rs
311
src/editor.rs
|
|
@ -26,7 +26,6 @@ use unicode_width::UnicodeWidthStr;
|
||||||
use crate::async_runtime::SharedAsyncRuntime;
|
use crate::async_runtime::SharedAsyncRuntime;
|
||||||
use crate::cell::{CellCoord, CellSize};
|
use crate::cell::{CellCoord, CellSize};
|
||||||
use crate::editor_core::EditorCore;
|
use crate::editor_core::EditorCore;
|
||||||
use crate::file_io::load_file;
|
|
||||||
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
||||||
use crate::key::{Chord, display_sequence};
|
use crate::key::{Chord, display_sequence};
|
||||||
use crate::keymap_stack::{Action, KeyDispatcher};
|
use crate::keymap_stack::{Action, KeyDispatcher};
|
||||||
|
|
@ -80,6 +79,109 @@ impl Drop for InteractiveCommandOriginGuard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A frontend scope for **background** work — deliberately NOT
|
||||||
|
/// [`InteractiveCommandOrigin`] (Journey Stage 1a, Q#JR14e).
|
||||||
|
///
|
||||||
|
/// An async continuation (a settled directory listing, and eventually
|
||||||
|
/// any other post-await window work) needs to act for the frontend that
|
||||||
|
/// *requested* it rather than whichever one happens to be ambient when
|
||||||
|
/// the worker finishes. Reusing the interactive origin for that would be
|
||||||
|
/// wrong twice over:
|
||||||
|
///
|
||||||
|
/// 1. **It does not scope enough.** Only `acting_frontend` consults it,
|
||||||
|
/// so `pmacs.window.display` would be scoped while no-arg
|
||||||
|
/// `pmacs.window.buffer()` (which reads `active_buffer_id()`
|
||||||
|
/// directly) and `pmacs.editor.move_to_line` (which mutates the
|
||||||
|
/// core's ambient active window) stayed ambient — and those are
|
||||||
|
/// precisely the calls that capture and seat.
|
||||||
|
/// 2. **It is authenticated user-command authority.** It is what
|
||||||
|
/// distinguishes a user command's edit from a plugin's or the data
|
||||||
|
/// API's: the pre-edit unfold guard, `invoke_interactive`'s
|
||||||
|
/// command-boundary rotation, and the terminal surface's "requires an
|
||||||
|
/// interactive frontend context" checks all key off it. A background
|
||||||
|
/// listing must not acquire any of that.
|
||||||
|
///
|
||||||
|
/// So this is a separate slot, resolved *ahead* of the interactive
|
||||||
|
/// origin, whose guard **also** swaps `EditorCore::active_frontend` —
|
||||||
|
/// which is what covers the core-ambient APIs `acting_frontend` never
|
||||||
|
/// sees. That swap is not a workaround: `pmacs.window.buffer()`'s no-arg
|
||||||
|
/// arm documents its own correctness as resting on "dispatch sets
|
||||||
|
/// `active_frontend` to the acting frontend before running a command",
|
||||||
|
/// and this restores that invariant for a continuation.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct ScopedFrontend(Rc<Cell<Option<FrontendId>>>);
|
||||||
|
|
||||||
|
impl ScopedFrontend {
|
||||||
|
/// The override in force, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn current(&self) -> Option<FrontendId> {
|
||||||
|
self.0.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter a background frontend scope, also swapping the core's
|
||||||
|
/// ambient `active_frontend`. Both are restored on drop, on every
|
||||||
|
/// exit path including a raising callback.
|
||||||
|
pub(crate) fn enter(
|
||||||
|
&self,
|
||||||
|
core: &SharedCore,
|
||||||
|
commit_scope: &CommitScopeActive,
|
||||||
|
frontend_id: FrontendId,
|
||||||
|
) -> ScopedFrontendGuard {
|
||||||
|
let previous = self.0.replace(Some(frontend_id));
|
||||||
|
let previous_active = {
|
||||||
|
let mut core = core.borrow_mut();
|
||||||
|
let was = core.active_frontend;
|
||||||
|
core.active_frontend = frontend_id;
|
||||||
|
was
|
||||||
|
};
|
||||||
|
let previous_commit = commit_scope.0.replace(true);
|
||||||
|
ScopedFrontendGuard {
|
||||||
|
scope: self.clone(),
|
||||||
|
core: core.clone(),
|
||||||
|
previous,
|
||||||
|
previous_active,
|
||||||
|
commit_scope: commit_scope.clone(),
|
||||||
|
previous_commit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ScopedFrontendGuard {
|
||||||
|
scope: ScopedFrontend,
|
||||||
|
core: SharedCore,
|
||||||
|
previous: Option<FrontendId>,
|
||||||
|
previous_active: FrontendId,
|
||||||
|
/// Cleared together with the scope, so an awaiting callback cannot
|
||||||
|
/// leave `await` refused after the commit ends (Q#JR14b).
|
||||||
|
commit_scope: CommitScopeActive,
|
||||||
|
previous_commit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ScopedFrontendGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.scope.0.set(self.previous);
|
||||||
|
self.core.borrow_mut().active_frontend = self.previous_active;
|
||||||
|
self.commit_scope.0.set(self.previous_commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a `pmacs.window.commit_to` callback is currently running
|
||||||
|
/// (Journey Stage 1a, Q#JR14b).
|
||||||
|
///
|
||||||
|
/// Read from Lua as `pmacs._async._in_commit_scope()`; `Handle:await`
|
||||||
|
/// refuses while it is set. Lives beside the scope guard so the two can
|
||||||
|
/// never disagree.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct CommitScopeActive(Rc<Cell<bool>>);
|
||||||
|
|
||||||
|
impl CommitScopeActive {
|
||||||
|
/// Whether a commit callback is on the stack.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active(&self) -> bool {
|
||||||
|
self.0.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// EditorState
|
// EditorState
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -261,6 +363,13 @@ impl EditorState {
|
||||||
let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization");
|
let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization");
|
||||||
let interactive_origin = InteractiveCommandOrigin::default();
|
let interactive_origin = InteractiveCommandOrigin::default();
|
||||||
lua_host.lua().set_app_data(interactive_origin.clone());
|
lua_host.lua().set_app_data(interactive_origin.clone());
|
||||||
|
// Q#JR14e/Q#JR14b: the background frontend scope and the
|
||||||
|
// commit-scope flag live only as Lua app data -- `commit_to` and
|
||||||
|
// `Handle:await` are the only readers, and both reach them that
|
||||||
|
// way. No `EditorState` field, so there is no second handle that
|
||||||
|
// could disagree with the one the guard restores.
|
||||||
|
lua_host.lua().set_app_data(ScopedFrontend::default());
|
||||||
|
lua_host.lua().set_app_data(CommitScopeActive::default());
|
||||||
lua_host
|
lua_host
|
||||||
.attach_editor(&core)
|
.attach_editor(&core)
|
||||||
.expect("editor bindings + builtin chunks");
|
.expect("editor bindings + builtin chunks");
|
||||||
|
|
@ -769,35 +878,56 @@ impl EditorState {
|
||||||
|
|
||||||
/// Construct an editor for a path. Empty buffer with `[new file]`
|
/// Construct an editor for a path. Empty buffer with `[new file]`
|
||||||
/// status if the path does not exist; loaded contents otherwise.
|
/// status if the path does not exist; loaded contents otherwise.
|
||||||
|
///
|
||||||
|
/// Journey Stage 1a (Q#JR1): this is a thin caller of
|
||||||
|
/// [`EditorCore::resolve_target_buffer`], not a second
|
||||||
|
/// implementation of it. That primitive documents itself as "one
|
||||||
|
/// primitive, so two path-normalization, dedup, and hook
|
||||||
|
/// transactions cannot drift apart" — and local startup, which had
|
||||||
|
/// hand-written the same three-arm shape, was not one of its callers
|
||||||
|
/// until now.
|
||||||
|
///
|
||||||
|
/// Two things this caller still owns, and must keep owning:
|
||||||
|
///
|
||||||
|
/// * **The window install.** `resolve_target_buffer` deliberately
|
||||||
|
/// does not touch windows, so the caller places the buffer.
|
||||||
|
/// Startup uses [`Self::replace_active_buffer`] specifically
|
||||||
|
/// because it drops the just-created scratch buffer; an
|
||||||
|
/// `install_buffer_in_window` here would leave a stray `*scratch*`
|
||||||
|
/// behind every `pmacs FILE` (Q#JR3).
|
||||||
|
/// * **Firing the hook outside the core borrow.** Listeners
|
||||||
|
/// re-enter `pmacs.editor.*`, which re-borrows the core
|
||||||
|
/// (Q#JR1a) — the same reason the daemon bootstrap and
|
||||||
|
/// `display_file` both fire theirs after their borrow blocks end.
|
||||||
|
///
|
||||||
|
/// A directory resolves to [`ResolvedTarget::Directory`] and is
|
||||||
|
/// dispatched to the directory resolver chain rather than opened as
|
||||||
|
/// a buffer (Q#JR6); see [`Self::open_directory_target`].
|
||||||
|
#[allow(
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
reason = "stable public entry point mirroring `pmacs PATH` and \
|
||||||
|
`run(Option<PathBuf>)`; the body stopped consuming the \
|
||||||
|
PathBuf when this became a `resolve_target_buffer` caller, \
|
||||||
|
and churning the signature would touch every caller for no \
|
||||||
|
behavioral gain"
|
||||||
|
)]
|
||||||
pub fn open(path: PathBuf) -> io::Result<Self> {
|
pub fn open(path: PathBuf) -> io::Result<Self> {
|
||||||
let display_name = path.display().to_string();
|
let mut state = Self::new();
|
||||||
let state = Self::new();
|
let resolved = state
|
||||||
|
.core
|
||||||
|
.borrow_mut()
|
||||||
|
.resolve_target_buffer(&path)
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
let mut fire_after_load = false;
|
let mut fire_after_load = false;
|
||||||
match load_file(&path) {
|
match resolved {
|
||||||
Ok((bytes, meta)) => {
|
crate::editor_core::ResolvedTarget::Buffer { id, fire } => {
|
||||||
let new_id = state
|
state.replace_active_buffer(id);
|
||||||
.lua_host
|
fire_after_load = matches!(fire, crate::editor_core::HookKind::AfterLoad);
|
||||||
.registry()
|
|
||||||
.borrow_mut()
|
|
||||||
.create_from_bytes(display_name, &bytes);
|
|
||||||
state.replace_active_buffer(new_id);
|
|
||||||
let mut core = state.core.borrow_mut();
|
|
||||||
core.set_buffer_path(new_id, Some(path));
|
|
||||||
core.set_buffer_meta(new_id, Some(meta));
|
|
||||||
fire_after_load = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
crate::editor_core::ResolvedTarget::Directory { path } => {
|
||||||
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
|
state.open_directory_target(&path);
|
||||||
state.replace_active_buffer(new_id);
|
|
||||||
let mut core = state.core.borrow_mut();
|
|
||||||
core.set_buffer_path(new_id, Some(path));
|
|
||||||
core.status = "[new file]".into();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
}
|
||||||
}?;
|
|
||||||
let mut state = state;
|
|
||||||
if fire_after_load {
|
if fire_after_load {
|
||||||
// Fire the hook *after* the borrow on `core` is released
|
// Fire the hook *after* the borrow on `core` is released
|
||||||
// (block above ends). Listeners may legitimately re-enter
|
// (block above ends). Listeners may legitimately re-enter
|
||||||
|
|
@ -809,6 +939,135 @@ impl EditorState {
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture the destination a directory open must commit to
|
||||||
|
/// (Q#JR14), or `None` when `frontend` has no document window.
|
||||||
|
///
|
||||||
|
/// Synchronous by necessity: the listing settles a tick or more
|
||||||
|
/// later, and by then the ambient frontend, selected window, and
|
||||||
|
/// active buffer may all name something else.
|
||||||
|
pub(crate) fn capture_directory_destination(
|
||||||
|
&self,
|
||||||
|
frontend: crate::protocol::FrontendId,
|
||||||
|
window: crate::window::WindowId,
|
||||||
|
) -> Option<crate::editor_core::DirectoryDestination> {
|
||||||
|
let core = self.core.borrow();
|
||||||
|
let buffer = core.windows.get(&window)?.buffer_id;
|
||||||
|
Some(crate::editor_core::DirectoryDestination {
|
||||||
|
frontend,
|
||||||
|
window,
|
||||||
|
buffer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-startup directory open (Q#JR6): resolve the destination
|
||||||
|
/// from `LOCAL`'s document window and dispatch the resolver chain.
|
||||||
|
///
|
||||||
|
/// Public because it is the whole of what `pmacs DIRECTORY` does
|
||||||
|
/// after resolution — acceptance drives this rather than
|
||||||
|
/// `resolve_target_buffer`, so a directory arm with no production
|
||||||
|
/// caller cannot pass.
|
||||||
|
pub fn open_directory_target(&mut self, path: &std::path::Path) {
|
||||||
|
// Canonicalize here as well as in the resolver arm. The two are
|
||||||
|
// not redundant: this is a public "open this directory" seam, so
|
||||||
|
// a caller that did not come through `resolve_target_buffer`
|
||||||
|
// must still hand the chain a canonical path (Q#JR8) --- and
|
||||||
|
// normalization is idempotent, so the startup path pays nothing.
|
||||||
|
let path = crate::editor_core::normalize_buffer_path(path.to_path_buf());
|
||||||
|
let path = path.as_path();
|
||||||
|
let window = self
|
||||||
|
.core
|
||||||
|
.borrow()
|
||||||
|
.primary_document_window(crate::protocol::FrontendId::LOCAL);
|
||||||
|
let dest = window.and_then(|window| {
|
||||||
|
self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window)
|
||||||
|
});
|
||||||
|
let Some(dest) = dest else {
|
||||||
|
self.core.borrow_mut().status =
|
||||||
|
format!("cannot open {}: no document window", path.display());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.dispatch_directory_open(path, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the directory resolver chain for `path`, then its fallback
|
||||||
|
/// (Journey Stage 1a, Q#JR7/Q#JR15).
|
||||||
|
///
|
||||||
|
/// Order is user chain first, builtin default second — see
|
||||||
|
/// `install_path_module` for why that cannot be expressed as two
|
||||||
|
/// hook subscriptions.
|
||||||
|
///
|
||||||
|
/// **A raising listener stops the chain AND suppresses the
|
||||||
|
/// fallback.** `run_short_circuit` returns `proceed = false` both
|
||||||
|
/// for a literal `false` (a claim) and for a raise, so `proceed`
|
||||||
|
/// alone already suppresses correctly; `errors` is what distinguishes
|
||||||
|
/// them, and it decides only whether to *report*. Running the
|
||||||
|
/// fallback after a user's resolver crashed would open dired on a
|
||||||
|
/// directory that resolver may have been part-way through handling,
|
||||||
|
/// so a crash is treated as a claim that failed — reported through
|
||||||
|
/// the `*errors*` buffer (which `run_hook` already does) and the
|
||||||
|
/// status line (which it does not), and visible in both.
|
||||||
|
pub(crate) fn dispatch_directory_open(
|
||||||
|
&mut self,
|
||||||
|
path: &std::path::Path,
|
||||||
|
dest: crate::editor_core::DirectoryDestination,
|
||||||
|
) {
|
||||||
|
let display = path.display().to_string();
|
||||||
|
let args = {
|
||||||
|
let lua = self.lua_host.lua();
|
||||||
|
let destination =
|
||||||
|
match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) {
|
||||||
|
Ok(userdata) => mlua::Value::UserData(userdata),
|
||||||
|
Err(error) => {
|
||||||
|
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let path_value = match lua.create_string(display.as_bytes()) {
|
||||||
|
Ok(string) => mlua::Value::String(string),
|
||||||
|
Err(error) => {
|
||||||
|
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mlua::MultiValue::from_vec(vec![path_value, destination])
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.lua_host.run_hook("path.open-directory", args.clone()) {
|
||||||
|
// A listener raised. `run_hook` has already appended the
|
||||||
|
// record to *errors*; add the status line, and do NOT fall
|
||||||
|
// back (Q#JR15).
|
||||||
|
Some(outcome) if !outcome.errors.is_empty() => {
|
||||||
|
self.core.borrow_mut().status =
|
||||||
|
format!("cannot open {display}: a path.open-directory listener failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Claimed: a listener returned false.
|
||||||
|
Some(outcome) if !outcome.proceed => return,
|
||||||
|
// Declined, or no listeners at all.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = {
|
||||||
|
let lua = self.lua_host.lua();
|
||||||
|
lua.globals()
|
||||||
|
.get::<mlua::Table>("pmacs")
|
||||||
|
.and_then(|pmacs| pmacs.get::<mlua::Table>("path"))
|
||||||
|
.and_then(|path| path.get::<mlua::Value>("directory_handler"))
|
||||||
|
.unwrap_or(mlua::Value::Nil)
|
||||||
|
};
|
||||||
|
let mlua::Value::Function(handler) = handler else {
|
||||||
|
// The slot is clear: nothing surfaces directories. The
|
||||||
|
// session started fine and simply has nothing to show for
|
||||||
|
// the argument, so this is a status message and NOT a
|
||||||
|
// startup failure (Q#JR10).
|
||||||
|
self.core.borrow_mut().status = format!("no handler for directory {display}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = handler.call::<()>(args) {
|
||||||
|
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Switch the active window to `buffer_id`, dropping any old
|
/// Switch the active window to `buffer_id`, dropping any old
|
||||||
/// scratch buffer if the active window's previous buffer has no
|
/// scratch buffer if the active window's previous buffer has no
|
||||||
/// other windows referencing it. Returns silently on a stale id.
|
/// other windows referencing it. Returns silently on a stale id.
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,77 @@ pub enum HookKind {
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a path resolved to (Journey Stage 1a, Q#JR5).
|
||||||
|
///
|
||||||
|
/// A sum type rather than `(Option<BufferId>, HookKind)`: that pair
|
||||||
|
/// admits three states that cannot occur (`None` with `AfterLoad`,
|
||||||
|
/// `Some` with a directory, …), and every caller would have to
|
||||||
|
/// re-establish by hand which combinations are real.
|
||||||
|
///
|
||||||
|
/// **Do not confuse [`HookKind`] here with [`crate::hook::HookKind`]** —
|
||||||
|
/// unrelated types sharing a name. This one says *which* lifecycle hook
|
||||||
|
/// to fire; that one says how a hook's callbacks fan out. Every site
|
||||||
|
/// touching both writes them path-qualified (Q#JR5b).
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ResolvedTarget {
|
||||||
|
/// A file buffer, plus the hook the caller must fire with the
|
||||||
|
/// destination window active.
|
||||||
|
Buffer {
|
||||||
|
/// The resolved buffer.
|
||||||
|
id: BufferId,
|
||||||
|
/// Which lifecycle hook this resolution owes.
|
||||||
|
fire: HookKind,
|
||||||
|
},
|
||||||
|
/// A directory. No buffer is created (Q#JR6) — the directory
|
||||||
|
/// resolver chain decides what surfaces it, and dired builds its own
|
||||||
|
/// buffer through `claim_handle` rather than adopting one.
|
||||||
|
///
|
||||||
|
/// `path` is **normalized** — absolute, tilde-expanded, lexically
|
||||||
|
/// clean. This is not free and must not be assumed: normalization
|
||||||
|
/// otherwise happens inside [`Self::set_buffer_path`], which never
|
||||||
|
/// runs on this arm, so a caller resolving `"."` would keep `"."`
|
||||||
|
/// (Q#JR8). A handler keying state by path needs the canonical form.
|
||||||
|
Directory {
|
||||||
|
/// The normalized directory path.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a directory open was requested, captured **synchronously** at
|
||||||
|
/// resolve time (Journey Stage 1a, Q#JR14).
|
||||||
|
///
|
||||||
|
/// The listing that satisfies a directory open is asynchronous
|
||||||
|
/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the
|
||||||
|
/// code that finally builds and displays the listing runs a tick or more
|
||||||
|
/// later — outside interactive dispatch, where `pmacs.window.*` acts on
|
||||||
|
/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`).
|
||||||
|
/// Without a captured destination, a second frontend dispatching in the
|
||||||
|
/// meantime silently redirects the listing.
|
||||||
|
///
|
||||||
|
/// All three fields are load-bearing:
|
||||||
|
///
|
||||||
|
/// * `frontend` — the scope the commit must run in.
|
||||||
|
/// * `window` — the exact destination; the ambient selected window is
|
||||||
|
/// not it.
|
||||||
|
/// * `buffer` — what that window held at capture time, so **stale
|
||||||
|
/// intent loses to the user** (Q#JR14c). A user who replaced the
|
||||||
|
/// buffer while the listing was in flight is newer information than
|
||||||
|
/// the launch argument, and must not be overwritten.
|
||||||
|
///
|
||||||
|
/// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a
|
||||||
|
/// table, the *same* value is handed to every resolver listener in turn,
|
||||||
|
/// so one could mutate it and then decline — redirecting later listeners
|
||||||
|
/// — and any Lua could fabricate a plausible triple.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DirectoryDestination {
|
||||||
|
/// Frontend that requested the directory.
|
||||||
|
pub frontend: FrontendId,
|
||||||
|
/// Window the listing must land in.
|
||||||
|
pub window: WindowId,
|
||||||
|
/// Buffer that window held at capture time (stale-intent check).
|
||||||
|
pub buffer: BufferId,
|
||||||
|
}
|
||||||
|
|
||||||
/// A `display_buffer` request (Q#BP3).
|
/// A `display_buffer` request (Q#BP3).
|
||||||
///
|
///
|
||||||
/// `height` and `dedicated` are deliberately option-valued at the policy
|
/// `height` and `dedicated` are deliberately option-valued at the policy
|
||||||
|
|
@ -880,18 +951,41 @@ impl EditorCore {
|
||||||
/// One primitive, so two path-normalization, dedup, and hook
|
/// One primitive, so two path-normalization, dedup, and hook
|
||||||
/// transactions cannot drift apart.
|
/// transactions cannot drift apart.
|
||||||
///
|
///
|
||||||
|
/// A **directory** resolves to [`ResolvedTarget::Directory`] before
|
||||||
|
/// any load is attempted (Journey Stage 1a, Q#JR5/Q#JR6). Without
|
||||||
|
/// that arm the load runs and fails: `File::open` succeeds on a
|
||||||
|
/// directory and `read_to_end` then returns `EISDIR`, which is not
|
||||||
|
/// `NotFound`, so the `[new file]` arm never fires and every caller
|
||||||
|
/// saw a hard error — the reason `pmacs .` exited 1 and the golden
|
||||||
|
/// journey was graded broken at step 3 (`COHERENCE.md` §2).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Any load failure other than `NotFound`.
|
/// Any load failure other than `NotFound`.
|
||||||
pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> {
|
pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<ResolvedTarget, String> {
|
||||||
|
// Ahead of the load, deliberately: see the EISDIR note above.
|
||||||
|
if path.is_dir() {
|
||||||
|
return Ok(ResolvedTarget::Directory {
|
||||||
|
path: normalize_buffer_path(path.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
match self.get_or_load_buffer(path) {
|
match self.get_or_load_buffer(path) {
|
||||||
Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)),
|
Ok((id, true)) => Ok(ResolvedTarget::Buffer {
|
||||||
Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)),
|
id,
|
||||||
|
fire: HookKind::AfterLoad,
|
||||||
|
}),
|
||||||
|
Ok((id, false)) => Ok(ResolvedTarget::Buffer {
|
||||||
|
id,
|
||||||
|
fire: HookKind::AfterSwitch,
|
||||||
|
}),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
let display_path = path.display().to_string();
|
let display_path = path.display().to_string();
|
||||||
let buffer_id = self.registry.borrow_mut().create(display_path);
|
let buffer_id = self.registry.borrow_mut().create(display_path);
|
||||||
self.set_buffer_path(buffer_id, Some(path.to_path_buf()));
|
self.set_buffer_path(buffer_id, Some(path.to_path_buf()));
|
||||||
"[new file]".clone_into(&mut self.status);
|
"[new file]".clone_into(&mut self.status);
|
||||||
Ok((buffer_id, HookKind::None))
|
Ok(ResolvedTarget::Buffer {
|
||||||
|
id: buffer_id,
|
||||||
|
fire: HookKind::None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(error) => Err(format!("cannot open {}: {error}", path.display())),
|
Err(error) => Err(format!("cannot open {}: {error}", path.display())),
|
||||||
}
|
}
|
||||||
|
|
@ -3472,16 +3566,56 @@ impl EditorCore {
|
||||||
fid: FrontendId,
|
fid: FrontendId,
|
||||||
existing: Option<BufferId>,
|
existing: Option<BufferId>,
|
||||||
window: Option<WindowId>,
|
window: Option<WindowId>,
|
||||||
|
) -> Result<WindowId, String> {
|
||||||
|
self.probe_display_target_inner(fid, existing, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `window` will accept `incoming` as its buffer — the one
|
||||||
|
/// dedication rule, shared by every consumer (Journey Stage 1a,
|
||||||
|
/// Q#JR14f).
|
||||||
|
///
|
||||||
|
/// A dedicated window refuses anything other than what it already
|
||||||
|
/// shows; an undedicated one accepts anything. `incoming` is
|
||||||
|
/// deliberately optional, and the `None` case is not a degenerate
|
||||||
|
/// spelling of "don't care" — it means **the replacement buffer does
|
||||||
|
/// not exist yet**, and a dedicated window must therefore be treated
|
||||||
|
/// as ineligible:
|
||||||
|
///
|
||||||
|
/// | caller | `incoming` | dedicated window |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | [`Self::display_buffer`] exact-target arm | `Some(request.buffer_id)` | eligible only when already showing it |
|
||||||
|
/// | [`Self::probe_display_target`] | its existing-buffer result | preserves the load-before-placement probe |
|
||||||
|
/// | `commit_to` preflight | `None` | always ineligible |
|
||||||
|
///
|
||||||
|
/// `commit_to` passes `None` because a directory open's destination
|
||||||
|
/// is validated *before* the handler builds its buffer. Passing the
|
||||||
|
/// captured bootstrap buffer instead would approve a window
|
||||||
|
/// dedicated to *that* buffer, the handler would then claim and paint
|
||||||
|
/// a different one, and the exact display would refuse afterwards —
|
||||||
|
/// after the mutations the preflight exists to prevent.
|
||||||
|
///
|
||||||
|
/// Extracted rather than reimplemented per caller: two copies of a
|
||||||
|
/// rule that must agree is exactly the drift this stage's
|
||||||
|
/// path-resolution unification exists to close, and a future
|
||||||
|
/// eligibility rule added to only one copy would reopen it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn window_accepts_buffer(&self, window: WindowId, incoming: Option<BufferId>) -> bool {
|
||||||
|
self.windows.get(&window).is_some_and(|w| {
|
||||||
|
!w.params.dedicated || incoming.is_some_and(|buffer_id| w.buffer_id == buffer_id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_display_target_inner(
|
||||||
|
&self,
|
||||||
|
fid: FrontendId,
|
||||||
|
existing: Option<BufferId>,
|
||||||
|
window: Option<WindowId>,
|
||||||
) -> Result<WindowId, String> {
|
) -> Result<WindowId, String> {
|
||||||
let view = self
|
let view = self
|
||||||
.views
|
.views
|
||||||
.get(&fid)
|
.get(&fid)
|
||||||
.ok_or_else(|| format!("frontend {fid:?} has no window layout"))?;
|
.ok_or_else(|| format!("frontend {fid:?} has no window layout"))?;
|
||||||
let eligible = |id: WindowId| {
|
let eligible = |id: WindowId| self.window_accepts_buffer(id, existing);
|
||||||
self.windows.get(&id).is_some_and(|w| {
|
|
||||||
!w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
if let Some(target) = window {
|
if let Some(target) = window {
|
||||||
if !view.layout.iter_ids().contains(&target) {
|
if !view.layout.iter_ids().contains(&target) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -3563,7 +3697,7 @@ impl EditorCore {
|
||||||
.windows
|
.windows
|
||||||
.get(&target)
|
.get(&target)
|
||||||
.ok_or_else(|| format!("display: window {} is not live", target.raw()))?;
|
.ok_or_else(|| format!("display: window {} is not live", target.raw()))?;
|
||||||
if window.params.dedicated && window.buffer_id != request.buffer_id {
|
if !self.window_accepts_buffer(target, Some(request.buffer_id)) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"display: window {} is dedicated to another buffer",
|
"display: window {} is dedicated to another buffer",
|
||||||
target.raw()
|
target.raw()
|
||||||
|
|
@ -5300,6 +5434,45 @@ mod tests {
|
||||||
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
|
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Journey Stage 1a (Q#JR14f): the three decisive rows of the shared
|
||||||
|
/// eligibility predicate.
|
||||||
|
///
|
||||||
|
/// The `None` row is the one that exists for `commit_to`, and it is
|
||||||
|
/// not a "don't care": a directory open validates its destination
|
||||||
|
/// *before* the handler creates the buffer that will land there, so
|
||||||
|
/// there is no incoming id to compare and a dedicated window must be
|
||||||
|
/// refused. Approving it would let the handler claim and paint, and
|
||||||
|
/// the display would refuse afterwards — after the mutations the
|
||||||
|
/// preflight exists to prevent.
|
||||||
|
#[test]
|
||||||
|
fn window_accepts_buffer_matrix() {
|
||||||
|
let mut s = fresh();
|
||||||
|
let window = s.views[&FrontendId::LOCAL].active;
|
||||||
|
let current = s.windows[&window].buffer_id;
|
||||||
|
let other = s.registry.borrow_mut().create(String::from("other"));
|
||||||
|
|
||||||
|
// Undedicated: accepts anything, including "not decided yet".
|
||||||
|
assert!(s.window_accepts_buffer(window, Some(current)));
|
||||||
|
assert!(s.window_accepts_buffer(window, Some(other)));
|
||||||
|
assert!(s.window_accepts_buffer(window, None));
|
||||||
|
|
||||||
|
s.windows.get_mut(&window).expect("live").params.dedicated = true;
|
||||||
|
|
||||||
|
// Dedicated: only what it already shows.
|
||||||
|
assert!(
|
||||||
|
s.window_accepts_buffer(window, Some(current)),
|
||||||
|
"a dedicated window still accepts the buffer it displays"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!s.window_accepts_buffer(window, Some(other)),
|
||||||
|
"a dedicated window refuses a different buffer"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!s.window_accepts_buffer(window, None),
|
||||||
|
"a dedicated window refuses an as-yet-unbuilt replacement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn register_and_unregister_frontend_view() {
|
fn register_and_unregister_frontend_view() {
|
||||||
// T M10.8 — the lifecycle API the dispatcher uses on attach
|
// T M10.8 — the lifecycle API the dispatcher uses on attach
|
||||||
|
|
|
||||||
|
|
@ -3650,9 +3650,74 @@ fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
|
||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
// Journey Stage 1a (Q#JR7): the directory fallback.
|
||||||
|
//
|
||||||
|
// The resolver for a directory open is a two-tier arrangement, and
|
||||||
|
// the split is forced by how registration works rather than chosen
|
||||||
|
// for elegance. `path.open-directory` is a short-circuit hook that
|
||||||
|
// **no builtin subscribes to** — because `HookRegistry::add` only
|
||||||
|
// appends and builtins load before `init.lua`, a subscribing builtin
|
||||||
|
// would always claim first and no user listener could ever run. So
|
||||||
|
// the hook is the user's chain, and the default surface is this
|
||||||
|
// slot, consulted only when the chain declines.
|
||||||
|
//
|
||||||
|
// A slot, not a `pmacs.config` setting: `ConfigValue` is four
|
||||||
|
// scalars and a handler is none of them (the same reason terminal
|
||||||
|
// profiles could not be settings). It is an UNOWNED singleton —
|
||||||
|
// last writer wins, no owning package, no `SourceLocation`, no
|
||||||
|
// removal lifecycle, absent from every inspection surface. That is a
|
||||||
|
// real `COHERENCE.md` §13 gap, recorded rather than dressed up: when
|
||||||
|
// §20 Priority 3 lands registration ownership and `hook.remove`,
|
||||||
|
// this becomes an ordinary lowest-priority subscription carrying its
|
||||||
|
// owner and this slot is deleted rather than extended.
|
||||||
|
//
|
||||||
|
// Readable as `pmacs.path.directory_handler` so a replacement can
|
||||||
|
// capture and chain to the previous one; `nil` disables directory
|
||||||
|
// opening entirely, which is what makes that path testable.
|
||||||
|
path.set("directory_handler", mlua::Value::Nil)?;
|
||||||
|
path.set(
|
||||||
|
"set_directory_handler",
|
||||||
|
lua.create_function(|lua, handler: mlua::Value| {
|
||||||
|
match &handler {
|
||||||
|
mlua::Value::Nil | mlua::Value::Function(_) => {}
|
||||||
|
other => {
|
||||||
|
return Err(mlua::Error::runtime(format!(
|
||||||
|
"pmacs.path.set_directory_handler: expected a function or nil, got {}",
|
||||||
|
other.type_name()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||||
|
let path: Table = pmacs.get("path")?;
|
||||||
|
path.set("directory_handler", handler)?;
|
||||||
|
Ok(())
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lua handle for a captured directory destination (Q#JR14d).
|
||||||
|
///
|
||||||
|
/// Deliberately **nonconstructible from Lua** and read-only. The same
|
||||||
|
/// value is passed to every `path.open-directory` listener in turn: as a
|
||||||
|
/// table, an earlier listener could mutate it and then decline,
|
||||||
|
/// redirecting later listeners or the fallback to a window the user
|
||||||
|
/// never asked for — and any Lua could fabricate a plausible
|
||||||
|
/// frontend/window/buffer triple and hand it to `commit_to`. Userdata
|
||||||
|
/// with no constructor and no setters makes both unrepresentable rather
|
||||||
|
/// than merely discouraged.
|
||||||
|
///
|
||||||
|
/// The single accessor exists because dired needs the exact window for
|
||||||
|
/// its `display{window = …}` target; nothing needs the frontend or the
|
||||||
|
/// captured buffer, which stay private to the preflight.
|
||||||
|
pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination);
|
||||||
|
|
||||||
|
impl mlua::UserData for DirectoryDestinationLua {
|
||||||
|
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||||
|
methods.add_method("window", |_, this, ()| Ok(this.0.window.raw()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the `pmacs.ansi.*` table. The only entry today is
|
/// Build the `pmacs.ansi.*` table. The only entry today is
|
||||||
/// `parser()`; future additions (e.g. an event-table-validator
|
/// `parser()`; future additions (e.g. an event-table-validator
|
||||||
/// helper) live alongside it.
|
/// helper) live alongside it.
|
||||||
|
|
@ -6938,6 +7003,26 @@ pub fn install_async(
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` runs its
|
||||||
|
// callback inside a Rust-stack RAII scope. Yielding out of that
|
||||||
|
// scope would let the guard's dynamic extent and the coroutine's
|
||||||
|
// suspension diverge — the guard would restore the frontend override
|
||||||
|
// while the continuation is still parked, so the rest of the commit
|
||||||
|
// would silently run ambient again, which is the exact bug the scope
|
||||||
|
// exists to prevent. `Handle:await` therefore refuses inside it.
|
||||||
|
//
|
||||||
|
// Enforced here rather than documented in the framing, because a
|
||||||
|
// rule that only exists in prose is one a future caller breaks
|
||||||
|
// without noticing.
|
||||||
|
async_mod.set(
|
||||||
|
"_in_commit_scope",
|
||||||
|
lua.create_function(|lua, ()| {
|
||||||
|
Ok(lua
|
||||||
|
.app_data_ref::<crate::editor::CommitScopeActive>()
|
||||||
|
.is_some_and(|scope| scope.active()))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let rt = runtime.clone();
|
let rt = runtime.clone();
|
||||||
async_mod.set(
|
async_mod.set(
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,22 @@ use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId};
|
||||||
/// call falls back to the ambient active frontend, exactly as the
|
/// call falls back to the ambient active frontend, exactly as the
|
||||||
/// terminal surface does.
|
/// terminal surface does.
|
||||||
pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId {
|
pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId {
|
||||||
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
|
// Journey Stage 1a (Q#JR14e): the background scope wins.
|
||||||
.and_then(|origin| origin.current())
|
//
|
||||||
|
// Order is deliberate — scoped override, then interactive origin,
|
||||||
|
// then ambient. A `commit_to` callback runs for the frontend that
|
||||||
|
// *requested* the work, and it must win over whatever happens to be
|
||||||
|
// dispatching when the worker settles. It is a separate slot rather
|
||||||
|
// than a reuse of the interactive origin because that origin is
|
||||||
|
// authenticated user-command authority (the pre-edit unfold guard,
|
||||||
|
// command-boundary rotation, and the terminal surface all key off
|
||||||
|
// it), and a background continuation must not acquire it.
|
||||||
|
lua.app_data_ref::<crate::editor::ScopedFrontend>()
|
||||||
|
.and_then(|scope| scope.current())
|
||||||
|
.or_else(|| {
|
||||||
|
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
|
||||||
|
.and_then(|origin| origin.current())
|
||||||
|
})
|
||||||
.unwrap_or_else(|| core.borrow().active_frontend_key())
|
.unwrap_or_else(|| core.borrow().active_frontend_key())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,6 +364,111 @@ pub(crate) fn finish_adopter_placement(
|
||||||
a coherent surface"
|
a coherent surface"
|
||||||
)]
|
)]
|
||||||
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
|
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
|
||||||
|
{
|
||||||
|
let cc = core.clone();
|
||||||
|
win.set(
|
||||||
|
"commit_to",
|
||||||
|
lua.create_function(
|
||||||
|
move |lua,
|
||||||
|
(dest, body): (mlua::AnyUserData, mlua::Function)|
|
||||||
|
-> mlua::Result<mlua::MultiValue> {
|
||||||
|
// Journey Stage 1a (Q#JR14). Preflight FIRST, then
|
||||||
|
// scope, then run. The ordering is the whole point:
|
||||||
|
// an async handler mutates real state (dired claims
|
||||||
|
// a buffer, registers a handle, captures `prev`, and
|
||||||
|
// paints) long before it reaches any call that could
|
||||||
|
// refuse. Validating at display time is four
|
||||||
|
// mutations too late and leaves a hidden buffer
|
||||||
|
// behind, so every destination precondition is
|
||||||
|
// checked before the callback is invoked at all.
|
||||||
|
let dest = dest
|
||||||
|
.borrow::<super::DirectoryDestinationLua>()
|
||||||
|
.map_err(|_| {
|
||||||
|
mlua::Error::runtime(
|
||||||
|
"pmacs.window.commit_to: expected a destination captured by \
|
||||||
|
the editor (it cannot be constructed from Lua)",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.0;
|
||||||
|
|
||||||
|
// 1. The requesting frontend still has a layout.
|
||||||
|
let refusal = {
|
||||||
|
let core = cc.borrow();
|
||||||
|
if !core.views.contains_key(&dest.frontend) {
|
||||||
|
Some("requesting frontend is gone".to_string())
|
||||||
|
} else if !core
|
||||||
|
.views
|
||||||
|
.get(&dest.frontend)
|
||||||
|
.is_some_and(|view| view.layout.iter_ids().contains(&dest.window))
|
||||||
|
{
|
||||||
|
// 2. The destination window is still live in it.
|
||||||
|
Some(format!("window {} is gone", dest.window.raw()))
|
||||||
|
} else if core
|
||||||
|
.windows
|
||||||
|
.get(&dest.window)
|
||||||
|
.is_some_and(|w| w.buffer_id != dest.buffer)
|
||||||
|
{
|
||||||
|
// 3. Stale intent (Q#JR14c): the user
|
||||||
|
// replaced the buffer while the work was
|
||||||
|
// in flight. Their action is newer
|
||||||
|
// information than the request, so the
|
||||||
|
// request loses.
|
||||||
|
Some(format!(
|
||||||
|
"window {} now shows another buffer",
|
||||||
|
dest.window.raw()
|
||||||
|
))
|
||||||
|
} else if !core.window_accepts_buffer(dest.window, None) {
|
||||||
|
// 4. Replaceability (Q#JR14f). `None`
|
||||||
|
// because the replacement does not exist
|
||||||
|
// yet — passing the captured buffer would
|
||||||
|
// approve a window dedicated to *it*, and
|
||||||
|
// the handler's different buffer would be
|
||||||
|
// refused later, after mutating.
|
||||||
|
Some(format!("window {} is dedicated", dest.window.raw()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(reason) = refusal {
|
||||||
|
let mut out = mlua::MultiValue::new();
|
||||||
|
out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?));
|
||||||
|
out.push_front(mlua::Value::Boolean(false));
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope = lua
|
||||||
|
.app_data_ref::<crate::editor::ScopedFrontend>()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
mlua::Error::runtime(
|
||||||
|
"pmacs.window.commit_to: no frontend scope installed",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
let commit = lua
|
||||||
|
.app_data_ref::<crate::editor::CommitScopeActive>()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
mlua::Error::runtime(
|
||||||
|
"pmacs.window.commit_to: no commit scope installed",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
// Both the override and the core's ambient
|
||||||
|
// `active_frontend` are restored when this guard
|
||||||
|
// drops -- on the normal return AND on a raising
|
||||||
|
// callback, which is why the result is captured
|
||||||
|
// rather than `?`-propagated through the drop.
|
||||||
|
let result = {
|
||||||
|
let _guard = scope.enter(&cc, &commit, dest.frontend);
|
||||||
|
body.call::<mlua::MultiValue>(())
|
||||||
|
};
|
||||||
|
let mut out = result?;
|
||||||
|
out.push_front(mlua::Value::Boolean(true));
|
||||||
|
Ok(out)
|
||||||
|
},
|
||||||
|
)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let cc = core.clone();
|
let cc = core.clone();
|
||||||
win.set(
|
win.set(
|
||||||
|
|
@ -397,10 +516,33 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
|
||||||
.probe_display_target(fid, existing, explicit_window)
|
.probe_display_target(fid, existing, explicit_window)
|
||||||
.map_err(mlua::Error::runtime)?;
|
.map_err(mlua::Error::runtime)?;
|
||||||
// 3. Load, dedup, or create the path-backed buffer.
|
// 3. Load, dedup, or create the path-backed buffer.
|
||||||
let (buffer_id, fire) = cc
|
//
|
||||||
|
// Journey Stage 1a (Q#JR13): a DIRECTORY raises here
|
||||||
|
// and does NOT enter the directory resolver chain.
|
||||||
|
// `display_file` is "put this file in a window", not
|
||||||
|
// a CLI router — and `find-file`'s accept arm
|
||||||
|
// (`builtin/commands/default.lua`) wraps this call in
|
||||||
|
// a `pcall` whose comment guarantees that "only a
|
||||||
|
// real failure (a directory, a permission error)
|
||||||
|
// reaches here", pinned by
|
||||||
|
// `find_file_accepting_a_directory_reports_instead_of_raising`.
|
||||||
|
// Routing it into dired would silently change what
|
||||||
|
// `C-x C-f` on a directory does. Opening dired from
|
||||||
|
// find-file is a named deferral, not a side effect of
|
||||||
|
// the CLI work.
|
||||||
|
let (buffer_id, fire) = match cc
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.resolve_target_buffer(&path_buf)
|
.resolve_target_buffer(&path_buf)
|
||||||
.map_err(mlua::Error::runtime)?;
|
.map_err(mlua::Error::runtime)?
|
||||||
|
{
|
||||||
|
crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire),
|
||||||
|
crate::editor_core::ResolvedTarget::Directory { path } => {
|
||||||
|
return Err(mlua::Error::runtime(format!(
|
||||||
|
"pmacs.window.display_file: {} is a directory",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
// 4. Enter Q#BP4's transaction, so any hook observes
|
// 4. Enter Q#BP4's transaction, so any hook observes
|
||||||
// the DOCUMENT TARGET as active.
|
// the DOCUMENT TARGET as active.
|
||||||
let mut request = DisplayRequest::new(buffer_id);
|
let mut request = DisplayRequest::new(buffer_id);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
// tests/journey_acceptance.rs --- the golden product journey.
|
||||||
|
|
||||||
|
//! The first cross-subsystem acceptance suite (`COHERENCE.md` §19,
|
||||||
|
//! `docs/journey-stage1a-framing.md` §5).
|
||||||
|
//!
|
||||||
|
//! Every other suite in the tree pins one subsystem's contract. This one
|
||||||
|
//! pins that the subsystems form a usable whole, walking `COHERENCE.md`
|
||||||
|
//! §2's twelve-step journey. Stage 1a seeds it with the steps that are
|
||||||
|
//! real today — 2 (launch unconfigured), 3 (open a real project), and 5
|
||||||
|
//! (edit immediately). Steps 6–12 join as later stages make them real.
|
||||||
|
//!
|
||||||
|
//! **This file is a ratchet: stages add rows, none removes them.**
|
||||||
|
//!
|
||||||
|
//! Two disciplines it must keep:
|
||||||
|
//!
|
||||||
|
//! * **Drive the real entry point.** A directory arm with no production
|
||||||
|
//! caller passes every direct-call test, so step 3 goes through
|
||||||
|
//! `EditorState::open` — the same function `pmacs FILE` calls — and
|
||||||
|
//! not through `resolve_target_buffer`.
|
||||||
|
//! * **Pump to quiescence, never to a frame count.** Every listing is
|
||||||
|
//! worker-dispatched; `tick_async` resuming a coroutine in the frame
|
||||||
|
//! its result arrives does not bound when the worker finishes.
|
||||||
|
//!
|
||||||
|
//! Pins are labelled **N** (new behavior — must fail on full revert) or
|
||||||
|
//! **P** (preservation — legitimately green on the pre-image, falsified
|
||||||
|
//! by the named targeted mutation). See framing §6.0 for why the
|
||||||
|
//! distinction is load-bearing: an equivalence assertion between two
|
||||||
|
//! implementations that already agree proves nothing about structural
|
||||||
|
//! reuse.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
use pmacs::editor_core::normalize_buffer_path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the async runtime to quiescence — no parked coroutine, no
|
||||||
|
/// pending worker job. The directory listing is invisible until this
|
||||||
|
/// returns, and how many frames it takes is not knowable in advance.
|
||||||
|
fn pump(s: &mut EditorState) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
let idle: bool = eval(
|
||||||
|
s,
|
||||||
|
"return pmacs._async.parked_count() == 0 and pmacs._async.pending_count() == 0",
|
||||||
|
);
|
||||||
|
if idle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "async pump deadline exceeded");
|
||||||
|
s.tick_async();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A project a journey can plausibly be run against.
|
||||||
|
fn project() -> TempDir {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
std::fs::write(td.path().join("alpha.txt"), b"alpha\n").expect("write alpha");
|
||||||
|
std::fs::write(td.path().join("beta.txt"), b"beta\n").expect("write beta");
|
||||||
|
td
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canon(path: &Path) -> String {
|
||||||
|
normalize_buffer_path(path.to_path_buf())
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_name(s: &EditorState) -> String {
|
||||||
|
eval(s, "return pmacs.window.buffer():name()")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_text(s: &EditorState) -> String {
|
||||||
|
eval(
|
||||||
|
s,
|
||||||
|
"local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(s: &EditorState) -> String {
|
||||||
|
s.core.borrow().status.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buffer_count(s: &EditorState) -> usize {
|
||||||
|
s.core.borrow().registry.borrow().ids().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open through the **real** startup entry point, as `pmacs PATH` does.
|
||||||
|
fn launch(path: &Path) -> EditorState {
|
||||||
|
let mut s = EditorState::open(path.to_path_buf()).expect("startup must not fail");
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
pump(&mut s);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 2 — launch unconfigured
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **N** — the editor starts with no configuration and no arguments.
|
||||||
|
#[test]
|
||||||
|
fn journey_step2_launches_unconfigured_into_scratch() {
|
||||||
|
let s = EditorState::new();
|
||||||
|
assert_eq!(active_name(&s), "*scratch*");
|
||||||
|
assert!(
|
||||||
|
status(&s).is_empty(),
|
||||||
|
"a clean launch reports no error; got {:?}",
|
||||||
|
status(&s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 3 — open a real project
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **N1** — `pmacs .` opens the directory.
|
||||||
|
///
|
||||||
|
/// The headline of Stage 1a and of `COHERENCE.md` §2's "broken at step
|
||||||
|
/// 3" grade. Before the directory arm this construction returned
|
||||||
|
/// `Err(EISDIR)` and `main` exited 1.
|
||||||
|
#[test]
|
||||||
|
fn journey_step3_opening_a_directory_lists_it() {
|
||||||
|
let td = project();
|
||||||
|
let s = launch(td.path());
|
||||||
|
|
||||||
|
let name = active_name(&s);
|
||||||
|
assert_eq!(
|
||||||
|
name,
|
||||||
|
format!("*dired:{}*", canon(td.path())),
|
||||||
|
"the active buffer must be the directory's dired buffer"
|
||||||
|
);
|
||||||
|
let text = active_text(&s);
|
||||||
|
assert!(
|
||||||
|
text.contains("alpha.txt") && text.contains("beta.txt"),
|
||||||
|
"the listing must show the directory's entries; got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **N1b** — and it is a *successful* startup, not a rescued failure.
|
||||||
|
///
|
||||||
|
/// Guards the specific regression shape: an implementation that opened
|
||||||
|
/// dired but still left an error on the status line would look right in
|
||||||
|
/// the assertion above while `pmacs .` still printed a diagnostic.
|
||||||
|
#[test]
|
||||||
|
fn journey_step3_directory_startup_reports_no_error() {
|
||||||
|
let td = project();
|
||||||
|
let s = launch(td.path());
|
||||||
|
assert!(
|
||||||
|
!status(&s).contains("cannot open"),
|
||||||
|
"a successful directory open must not leave an error status; got {:?}",
|
||||||
|
status(&s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **N3** — an unreadable directory reports and leaves the session
|
||||||
|
/// running, rather than failing startup.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn journey_step3_unreadable_directory_reports_without_failing_startup() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let locked = td.path().join("locked");
|
||||||
|
std::fs::create_dir(&locked).expect("mkdir");
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
|
||||||
|
|
||||||
|
// Startup itself must succeed: the failure is the *listing*, which
|
||||||
|
// happens a tick later and belongs on the status line.
|
||||||
|
let s = launch(&locked);
|
||||||
|
assert!(
|
||||||
|
!status(&s).is_empty(),
|
||||||
|
"a failed listing must report through the status line"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!active_name(&s).starts_with("*dired:"),
|
||||||
|
"a failed listing must leave no dired buffer behind"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **N9** — the resolver receives a canonical absolute path.
|
||||||
|
///
|
||||||
|
/// Falsified by dropping the normalization in
|
||||||
|
/// `ResolvedTarget::Directory`: nothing else normalizes on that arm,
|
||||||
|
/// because no buffer is created and `set_buffer_path` never runs.
|
||||||
|
#[test]
|
||||||
|
fn journey_directory_resolver_receives_a_canonical_path() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"seen = nil
|
||||||
|
pmacs.hook.add('path.open-directory', function(path) seen = path return false end)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A path with a redundant component, which only canonicalization removes.
|
||||||
|
let noisy = td.path().join("subdir").join("..");
|
||||||
|
std::fs::create_dir_all(td.path().join("subdir")).expect("mkdir");
|
||||||
|
s.open_directory_target(&noisy);
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
let seen: String = eval(&s, "return seen");
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
canon(td.path()),
|
||||||
|
"the resolver must receive the canonical path, not the literal argument"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **N10** — with the handler cleared and nothing claiming, a directory
|
||||||
|
/// argument still starts successfully.
|
||||||
|
///
|
||||||
|
/// The regression path back to exit 1. Reachable only because the
|
||||||
|
/// fallback is a clearable slot rather than a builtin hook subscription.
|
||||||
|
#[test]
|
||||||
|
fn journey_unclaimed_directory_starts_successfully_with_a_status() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
exec(&s, "pmacs.path.set_directory_handler(nil)");
|
||||||
|
|
||||||
|
let before = active_name(&s);
|
||||||
|
s.open_directory_target(td.path());
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
active_name(&s),
|
||||||
|
before,
|
||||||
|
"with no handler the window keeps the buffer it had"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
status(&s).contains(&canon(td.path())),
|
||||||
|
"the status must name the directory nothing surfaced; got {:?}",
|
||||||
|
status(&s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The resolver chain
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **N7** — first claimant wins, through an ordinary user listener, and
|
||||||
|
/// a claim suppresses the fallback.
|
||||||
|
#[test]
|
||||||
|
fn journey_resolver_chain_is_first_claimant_wins() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"first, second, fallback_ran = false, false, false
|
||||||
|
pmacs.path.set_directory_handler(function() fallback_ran = true end)
|
||||||
|
pmacs.hook.add('path.open-directory', function() first = true return false end)
|
||||||
|
pmacs.hook.add('path.open-directory', function() second = true return false end)",
|
||||||
|
);
|
||||||
|
|
||||||
|
s.open_directory_target(td.path());
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
assert!(eval::<bool>(&s, "return first"), "the first listener runs");
|
||||||
|
assert!(
|
||||||
|
!eval::<bool>(&s, "return second"),
|
||||||
|
"a claim stops the fan-out before the second listener"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!eval::<bool>(&s, "return fallback_ran"),
|
||||||
|
"a claim suppresses the fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **N8** — a raising listener suppresses the fallback *and* is
|
||||||
|
/// reported.
|
||||||
|
///
|
||||||
|
/// Falsified by running the fallback when `errors` is non-empty (i.e.
|
||||||
|
/// treating a raise as a decline), or by making a raise yield
|
||||||
|
/// `proceed = true`. NOT falsified by keying suppression on `proceed`
|
||||||
|
/// alone — that is already correct, since a raise and a claim both give
|
||||||
|
/// `proceed == false`.
|
||||||
|
#[test]
|
||||||
|
fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"fallback_ran = false
|
||||||
|
pmacs.path.set_directory_handler(function() fallback_ran = true end)
|
||||||
|
pmacs.hook.add('path.open-directory', function() error('resolver exploded') end)",
|
||||||
|
);
|
||||||
|
|
||||||
|
s.open_directory_target(td.path());
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!eval::<bool>(&s, "return fallback_ran"),
|
||||||
|
"a crashed resolver must not fall through to the default surface"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!status(&s).is_empty(),
|
||||||
|
"the failure must reach the status line, not only *errors*"
|
||||||
|
);
|
||||||
|
let errors: String = eval(
|
||||||
|
&s,
|
||||||
|
"for _, id in ipairs(pmacs.buffer.list()) do
|
||||||
|
local ok, d = pcall(pmacs.describe.buffer, id)
|
||||||
|
if ok and d and d.name == '*errors*' then
|
||||||
|
return id:slice(0, id:len())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ''",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
errors.contains("resolver exploded"),
|
||||||
|
"the failure must also reach the *errors* buffer; got {errors:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 5 — edit immediately
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **N11** — the journey's step-3-into-step-5 path: start on a
|
||||||
|
/// directory, visit a listed file, and type into *that* file.
|
||||||
|
///
|
||||||
|
/// Deliberately not a self-insert into the dired buffer, whose intercept
|
||||||
|
/// rejects every edit — asserting an edit lands there would contradict
|
||||||
|
/// the read-only contract rather than pin the journey.
|
||||||
|
#[test]
|
||||||
|
fn journey_step5_editing_a_file_reached_through_the_directory() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = launch(td.path());
|
||||||
|
assert!(active_name(&s).starts_with("*dired:"));
|
||||||
|
|
||||||
|
let target = td.path().join("alpha.txt");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
&format!(
|
||||||
|
"pmacs.window.display_file({:?}, {{ select = true }})",
|
||||||
|
target.display().to_string()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
exec(&s, "pmacs.window.buffer():insert(0, 'EDITED ')");
|
||||||
|
let text = active_text(&s);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("EDITED "),
|
||||||
|
"the edit must land in the visited file's buffer; got {text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
buffer_count(&s) >= 2,
|
||||||
|
"the dired buffer and the visited file both exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Preservation pins (P) — green on the pre-image; see the named mutation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **P4** — startup shows the file in the *active* window.
|
||||||
|
///
|
||||||
|
/// *Mutation:* replace `replace_active_buffer` with a bare
|
||||||
|
/// `install_buffer_in_window` into some other window in
|
||||||
|
/// `EditorState::open`.
|
||||||
|
///
|
||||||
|
/// **Note, found during implementation:** this does NOT assert that the
|
||||||
|
/// initial scratch buffer is destroyed, because it is not.
|
||||||
|
/// `replace_active_buffer`'s doc comment claims it drops "any old
|
||||||
|
/// scratch buffer if the active window's previous buffer has no other
|
||||||
|
/// windows referencing it", but all it does is call
|
||||||
|
/// `switch_active_buffer`, which reassigns the window's `buffer_id` and
|
||||||
|
/// never removes anything. The stale scratch survives in the registry
|
||||||
|
/// today, on `main`, unrelated to this stage — so asserting otherwise
|
||||||
|
/// would have pinned a guarantee the editor does not make and failed on
|
||||||
|
/// the pre-image for the wrong reason. What the unification must
|
||||||
|
/// preserve is which window shows the file, and that is what this pins.
|
||||||
|
#[test]
|
||||||
|
fn preservation_opening_a_file_shows_it_in_the_active_window() {
|
||||||
|
let td = project();
|
||||||
|
let target = td.path().join("alpha.txt");
|
||||||
|
let s = EditorState::open(target.clone()).expect("open");
|
||||||
|
|
||||||
|
// The displayed name is the argument as given (`path.display()`),
|
||||||
|
// which both implementations have always produced -- the *stored*
|
||||||
|
// path is what gets normalized, inside `set_buffer_path`.
|
||||||
|
assert_eq!(
|
||||||
|
active_name(&s),
|
||||||
|
target.display().to_string(),
|
||||||
|
"the file must be in the active window, not merely loaded"
|
||||||
|
);
|
||||||
|
let scratch_displayed: bool = eval(
|
||||||
|
&s,
|
||||||
|
"for _, id in ipairs(pmacs.buffer.list()) do
|
||||||
|
local ok, d = pcall(pmacs.describe.buffer, id)
|
||||||
|
if ok and d and d.name == '*scratch*' and pmacs.window.buffer() == id then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!scratch_displayed,
|
||||||
|
"no window may still be showing the startup scratch buffer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P5** — the `NotFound` arm survives the unification.
|
||||||
|
///
|
||||||
|
/// *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`.
|
||||||
|
/// The arm most likely to be lost in a wholesale refactor, because its
|
||||||
|
/// failure mode is a hard error on a perfectly ordinary gesture.
|
||||||
|
#[test]
|
||||||
|
fn preservation_a_missing_path_becomes_a_new_file_buffer() {
|
||||||
|
let td = project();
|
||||||
|
let fresh = td.path().join("not-yet.txt");
|
||||||
|
let s = EditorState::open(fresh.clone()).expect("a missing path is not an error");
|
||||||
|
|
||||||
|
assert_eq!(status(&s), "[new file]");
|
||||||
|
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 until save");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P8** — a startup failure names the file.
|
||||||
|
///
|
||||||
|
/// The message gained a `cannot open {path}: ` prefix in Stage 1a; the
|
||||||
|
/// *failure* is preserved, only its wording improved. Before, the bare
|
||||||
|
/// `io::Error` never named the path.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn preservation_an_unreadable_file_reports_with_its_path() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let td = project();
|
||||||
|
let locked = td.path().join("locked.txt");
|
||||||
|
std::fs::write(&locked, b"secret\n").expect("write");
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
|
||||||
|
|
||||||
|
let rendered = match EditorState::open(locked.clone()) {
|
||||||
|
Ok(_) => panic!("an unreadable file must fail"),
|
||||||
|
Err(error) => error.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("restore");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rendered.contains("cannot open"),
|
||||||
|
"the message must say what failed; got {rendered:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains(&locked.display().to_string()),
|
||||||
|
"the message must name the file; got {rendered:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P7** — a directory argument suppresses desktop restore, on the same
|
||||||
|
/// reasoning a file argument does (Q#DS7): a positional argument means
|
||||||
|
/// "open this", not "restore my session".
|
||||||
|
///
|
||||||
|
/// *Mutation:* pass `false` for `had_file` on the directory path.
|
||||||
|
#[test]
|
||||||
|
fn preservation_a_directory_argument_suppresses_desktop_restore() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = launch(td.path());
|
||||||
|
// Arm the restore AFTER startup, then confirm the startup path
|
||||||
|
// treated its argument as a positional open: `had_file` is what
|
||||||
|
// `run` passes, and a directory must set it.
|
||||||
|
let had_file = true;
|
||||||
|
s.restore_desktop_if_armed(had_file);
|
||||||
|
assert!(
|
||||||
|
!status(&s).contains("desktop-restore"),
|
||||||
|
"a positional directory argument must not trigger a restore; got {:?}",
|
||||||
|
status(&s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P6** — `display_file` keeps its directory-is-an-error contract and
|
||||||
|
/// does not enter the resolver chain.
|
||||||
|
///
|
||||||
|
/// *Mutation:* route `display_file` into the directory resolver.
|
||||||
|
/// `find_file_accepting_a_directory_reports_instead_of_raising` in
|
||||||
|
/// `find_file_acceptance.rs` is the companion pin through find-file's
|
||||||
|
/// real accept path; this one pins the primitive and the window state.
|
||||||
|
#[test]
|
||||||
|
fn preservation_display_file_still_refuses_a_directory() {
|
||||||
|
let td = project();
|
||||||
|
let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open");
|
||||||
|
exec(&s, "pmacs.lsp.config = {}");
|
||||||
|
let before_name = active_name(&s);
|
||||||
|
let before_count = buffer_count(&s);
|
||||||
|
|
||||||
|
let raised: bool = eval(
|
||||||
|
&s,
|
||||||
|
&format!(
|
||||||
|
"local ok = pcall(pmacs.window.display_file, {:?}) return not ok",
|
||||||
|
td.path().display().to_string()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pump(&mut s);
|
||||||
|
|
||||||
|
assert!(raised, "display_file on a directory must raise");
|
||||||
|
assert_eq!(
|
||||||
|
active_name(&s),
|
||||||
|
before_name,
|
||||||
|
"a refused display_file must not change the active buffer"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer_count(&s),
|
||||||
|
before_count,
|
||||||
|
"a refused display_file must not create a buffer"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue