T M4.5 L1: cross-file nav foundations — per-buffer file_path, jump ring, x-file go-to-def
Lays the groundwork for WorkspaceEdit/rename (L2+) by making navigation cross-file-correct. - Relocate file_path/file_meta from the EditorCore global onto Buffer itself, so each buffer keeps its own filesystem identity across cross-file navigation. Accessors + registry/editor/lua/ semantic_render call sites migrated; zero behavioural change for single-file flows. - uri->path: project_index::uri_to_path made pub; pmacs.lsp.path_for_uri. - find-or-open: BufferRegistry::find_by_path + pmacs.buffer.find_or_open dedups an already-open file instead of spawning a duplicate buffer (SP-4 Gap A). - Bounded jump ring on EditorCore (cap 64, oldest-evict, stale-buffer skip): push_jump/jump_back + pmacs.editor.* bindings + lsp.jump-back command bound to M-,. - pmacs.lsp.go_to_definition cross-file branch: decode URI -> push_jump -> find_or_open -> reposition, with a failure path that unwinds the pushed origin. ensure_server now passes cfg.env through. Tests: 5 jump-ring unit tests; m4_12_cross_file_go_to_definition_and_ jump_back end-to-end via a new `defenv` fake-LSP mode. All gates green (lib 1262/0, m4 67/0, m8_1/m8_9/m8_10, m9_1, m11_5 --features crdt 2/0). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a1a2369d3a
commit
f7267cd720
|
|
@ -162,6 +162,7 @@ local function ensure_server(language)
|
||||||
language_id = language,
|
language_id = language,
|
||||||
command = cfg.command,
|
command = cfg.command,
|
||||||
args = cfg.args or {},
|
args = cfg.args or {},
|
||||||
|
env = cfg.env,
|
||||||
init_options = cfg.init_options,
|
init_options = cfg.init_options,
|
||||||
settings = cfg.settings,
|
settings = cfg.settings,
|
||||||
})
|
})
|
||||||
|
|
@ -379,11 +380,37 @@ function pmacs.lsp.go_to_definition()
|
||||||
end
|
end
|
||||||
local first = locs[1]
|
local first = locs[1]
|
||||||
if first.uri == rec.uri then
|
if first.uri == rec.uri then
|
||||||
|
-- Same file: record the origin so M-, returns here, then move.
|
||||||
|
pmacs.editor.push_jump()
|
||||||
move_active_cursor_to(first.line, first.col)
|
move_active_cursor_to(first.line, first.col)
|
||||||
pmacs.editor.set_status(string.format(
|
pmacs.editor.set_status(string.format(
|
||||||
"LSP: definition at %d:%d", first.line + 1, first.col + 1))
|
"LSP: definition at %d:%d", first.line + 1, first.col + 1))
|
||||||
else
|
else
|
||||||
pmacs.editor.set_status("LSP: definition lives in " .. first.uri)
|
-- Cross-file (SP-4): decode the URI, record the jump origin
|
||||||
|
-- *before* switching away, open-or-reuse the target buffer,
|
||||||
|
-- then position the cursor. `find_or_open` switches the active
|
||||||
|
-- buffer and fires `buffer.after-load`, which attaches an LSP
|
||||||
|
-- to the newly opened file.
|
||||||
|
local path = pmacs.lsp.path_for_uri(first.uri)
|
||||||
|
if not path then
|
||||||
|
pmacs.editor.set_status(
|
||||||
|
"LSP: cannot open non-file definition " .. first.uri)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
pmacs.editor.push_jump()
|
||||||
|
local ok2, oerr = pcall(pmacs.buffer.find_or_open, path)
|
||||||
|
if not ok2 then
|
||||||
|
-- Open failed: drop the origin we just pushed so M-, isn't
|
||||||
|
-- left pointing at a jump that never happened.
|
||||||
|
pmacs.editor.jump_back()
|
||||||
|
pmacs.editor.set_status(
|
||||||
|
"LSP: failed to open " .. path .. ": " .. tostring(oerr))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
move_active_cursor_to(first.line, first.col)
|
||||||
|
pmacs.editor.set_status(string.format(
|
||||||
|
"LSP: definition at %s:%d:%d",
|
||||||
|
path, first.line + 1, first.col + 1))
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -571,12 +598,26 @@ pmacs.command.define {
|
||||||
fn = pmacs.lsp.document_symbols,
|
fn = pmacs.lsp.document_symbols,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- T M4.5 L1 — unwind the cross-file jump ring. Pairs with the
|
||||||
|
-- `pmacs.editor.push_jump()` every navigation action records before
|
||||||
|
-- it moves the cursor.
|
||||||
|
pmacs.command.define {
|
||||||
|
name = "lsp.jump-back",
|
||||||
|
description = "Return to the location before the last LSP navigation jump.",
|
||||||
|
fn = function()
|
||||||
|
if not pmacs.editor.jump_back() then
|
||||||
|
pmacs.editor.set_status("LSP: jump ring empty")
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
-- Default chords. M-. follows the cross-editor convention for
|
-- Default chords. M-. follows the cross-editor convention for
|
||||||
-- go-to-definition; the others sit on `C-c` to keep printable letters
|
-- go-to-definition; the others sit on `C-c` to keep printable letters
|
||||||
-- self-inserting. The user can override or unbind any of these from
|
-- self-inserting. The user can override or unbind any of these from
|
||||||
-- init.lua.
|
-- init.lua.
|
||||||
pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-definition" }
|
pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-definition" }
|
||||||
pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" }
|
pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" }
|
||||||
|
pmacs.keymap.bind { scope = "global", sequence = "M-,", command = "lsp.jump-back" }
|
||||||
pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" }
|
pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" }
|
||||||
pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" }
|
pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" }
|
||||||
pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" }
|
pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" }
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,19 @@ fn main() {
|
||||||
serde_json::Value::from(format!("pos:{ch}")),
|
serde_json::Value::from(format!("pos:{ch}")),
|
||||||
serde_json::json!({ "start": pos, "end": pos }),
|
serde_json::json!({ "start": pos, "end": pos }),
|
||||||
)
|
)
|
||||||
|
} else if mode == "defenv" {
|
||||||
|
// T M4.5 L1 cross-file: point the definition at a
|
||||||
|
// *different* file URI supplied via env, so the
|
||||||
|
// client must decode the URI, open-or-reuse that
|
||||||
|
// buffer, and reposition (SP-4 Gap A path).
|
||||||
|
let target = std::env::var("PMACS_FAKE_LSP_DEF_URI").unwrap_or_default();
|
||||||
|
(
|
||||||
|
serde_json::Value::from(target),
|
||||||
|
serde_json::json!({
|
||||||
|
"start": { "line": 2, "character": 0 },
|
||||||
|
"end": { "line": 2, "character": 3 }
|
||||||
|
}),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
uri,
|
uri,
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@
|
||||||
//! moves its view list out of `self` before iterating, so the views can
|
//! moves its view list out of `self` before iterating, so the views can
|
||||||
//! observe `&Buffer` while the buffer's own `&mut self` is held.
|
//! observe `&Buffer` while the buffer's own `&mut self` is held.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use crate::file_io::FileMeta;
|
||||||
use crate::rope::{Edit, Position, Range, Rope, RopeError};
|
use crate::rope::{Edit, Position, Range, Rope, RopeError};
|
||||||
use crate::view::{InterceptContext, View};
|
use crate::view::{InterceptContext, View};
|
||||||
|
|
||||||
|
|
@ -209,6 +211,16 @@ pub struct Buffer {
|
||||||
undo: Vec<UndoEntry>,
|
undo: Vec<UndoEntry>,
|
||||||
/// Redo stack. Cleared by any forward edit.
|
/// Redo stack. Cleared by any forward edit.
|
||||||
redo: Vec<UndoEntry>,
|
redo: Vec<UndoEntry>,
|
||||||
|
/// Path this buffer is bound to on disk, if any (T M4.5 L1:
|
||||||
|
/// relocated here from `EditorCore` so cross-file navigation can
|
||||||
|
/// keep each buffer's identity straight — the v0.1 single-file
|
||||||
|
/// `EditorCore.file_path` shortcut no longer holds once multiple
|
||||||
|
/// files are open). `None` for scratch / unsaved buffers.
|
||||||
|
file_path: Option<PathBuf>,
|
||||||
|
/// Filesystem metadata captured at the last successful load/save,
|
||||||
|
/// used for external-change detection. Relocated alongside
|
||||||
|
/// [`Self::file_path`].
|
||||||
|
file_meta: Option<FileMeta>,
|
||||||
/// True while an edit is in flight on this buffer (T M7.4).
|
/// True while an edit is in flight on this buffer (T M7.4).
|
||||||
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
|
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
|
||||||
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
|
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
|
||||||
|
|
@ -267,12 +279,36 @@ impl Buffer {
|
||||||
next_mark_id: 0,
|
next_mark_id: 0,
|
||||||
undo: Vec::new(),
|
undo: Vec::new(),
|
||||||
redo: Vec::new(),
|
redo: Vec::new(),
|
||||||
|
file_path: None,
|
||||||
|
file_meta: None,
|
||||||
editing_in_progress: false,
|
editing_in_progress: false,
|
||||||
#[cfg(feature = "crdt")]
|
#[cfg(feature = "crdt")]
|
||||||
crdt: None,
|
crdt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path this buffer is bound to on disk, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_path(&self) -> Option<&Path> {
|
||||||
|
self.file_path.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind (or unbind, with `None`) this buffer to a disk path.
|
||||||
|
pub fn set_file_path(&mut self, path: Option<PathBuf>) {
|
||||||
|
self.file_path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem metadata from the last load/save, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_meta(&self) -> Option<&FileMeta> {
|
||||||
|
self.file_meta.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record filesystem metadata (after a successful load/save).
|
||||||
|
pub fn set_file_meta(&mut self, meta: Option<FileMeta>) {
|
||||||
|
self.file_meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
/// Construct an empty CRDT-backed buffer.
|
/// Construct an empty CRDT-backed buffer.
|
||||||
///
|
///
|
||||||
/// `peer_id` identifies this frontend's edits in the CRDT op
|
/// `peer_id` identifies this frontend's edits in the CRDT op
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,19 @@ impl BufferRegistry {
|
||||||
.find(|id| self.buffers.get(id).is_some_and(|b| b.name() == name))
|
.find(|id| self.buffers.get(id).is_some_and(|b| b.name() == name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First buffer bound to `path` on disk, in insertion order.
|
||||||
|
/// T M4.5 L1: matches on the per-buffer `file_path` (set at
|
||||||
|
/// load), so cross-file navigation reuses an already-open file
|
||||||
|
/// instead of creating a duplicate buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn find_by_path(&self, path: &std::path::Path) -> Option<BufferId> {
|
||||||
|
self.order.iter().copied().find(|id| {
|
||||||
|
self.buffers
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|b| b.file_path() == Some(path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// IDs in insertion order. Stable: preserved across non-removing
|
/// IDs in insertion order. Stable: preserved across non-removing
|
||||||
/// operations.
|
/// operations.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
|
|
@ -387,8 +387,8 @@ impl EditorState {
|
||||||
.create_from_bytes(display_name, &bytes);
|
.create_from_bytes(display_name, &bytes);
|
||||||
state.replace_active_buffer(new_id);
|
state.replace_active_buffer(new_id);
|
||||||
let mut core = state.core.borrow_mut();
|
let mut core = state.core.borrow_mut();
|
||||||
core.file_path = Some(path);
|
core.set_buffer_path(new_id, Some(path));
|
||||||
core.file_meta = Some(meta);
|
core.set_buffer_meta(new_id, Some(meta));
|
||||||
fire_after_load = true;
|
fire_after_load = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +396,7 @@ impl EditorState {
|
||||||
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
|
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
|
||||||
state.replace_active_buffer(new_id);
|
state.replace_active_buffer(new_id);
|
||||||
let mut core = state.core.borrow_mut();
|
let mut core = state.core.borrow_mut();
|
||||||
core.file_path = Some(path);
|
core.set_buffer_path(new_id, Some(path));
|
||||||
core.status = "[new file]".into();
|
core.status = "[new file]".into();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1838,8 +1838,8 @@ mod tests {
|
||||||
let s = EditorState::open(path.clone()).expect("must succeed");
|
let s = EditorState::open(path.clone()).expect("must succeed");
|
||||||
let core = s.core.borrow();
|
let core = s.core.borrow();
|
||||||
assert!(core.active_buffer_len() == 0);
|
assert!(core.active_buffer_len() == 0);
|
||||||
assert_eq!(core.file_path.as_deref(), Some(path.as_path()));
|
assert_eq!(core.active_buffer_path().as_deref(), Some(path.as_path()));
|
||||||
assert!(core.file_meta.is_none());
|
assert!(core.active_file_meta().is_none());
|
||||||
assert_eq!(core.status, "[new file]");
|
assert_eq!(core.status, "[new file]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1851,7 +1851,7 @@ mod tests {
|
||||||
let s = EditorState::open(path.clone()).expect("must succeed");
|
let s = EditorState::open(path.clone()).expect("must succeed");
|
||||||
let core = s.core.borrow();
|
let core = s.core.borrow();
|
||||||
assert_eq!(core.active_buffer_len(), 5);
|
assert_eq!(core.active_buffer_len(), 5);
|
||||||
assert!(core.file_meta.is_some());
|
assert!(core.active_file_meta().is_some());
|
||||||
assert_eq!(core.status, "");
|
assert_eq!(core.status, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
//! caches synchronized.
|
//! caches synchronized.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferId, EditOp};
|
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||||
use crate::file_io::{FileMeta, save_atomic};
|
use crate::file_io::{FileMeta, save_atomic};
|
||||||
|
|
@ -83,15 +83,6 @@ pub struct EditorCore {
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// True iff the editor should exit at the next iteration.
|
/// True iff the editor should exit at the next iteration.
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
/// File backing the active window's buffer, if any.
|
|
||||||
///
|
|
||||||
/// Kept on the core (rather than per-buffer) for v0.1 single-
|
|
||||||
/// file workflows. Multi-file open will need this on the buffer
|
|
||||||
/// itself or in a side table; for now, M2.8 doesn't exercise the
|
|
||||||
/// distinction.
|
|
||||||
pub file_path: Option<PathBuf>,
|
|
||||||
/// File metadata at the last load or save.
|
|
||||||
pub file_meta: Option<FileMeta>,
|
|
||||||
/// Universal minibuffer (T M2.7).
|
/// Universal minibuffer (T M2.7).
|
||||||
pub minibuffer: Minibuffer,
|
pub minibuffer: Minibuffer,
|
||||||
/// The frontend that produced the most recent input event
|
/// The frontend that produced the most recent input event
|
||||||
|
|
@ -126,6 +117,16 @@ pub struct EditorCore {
|
||||||
/// active frontend's mirror would silently drift from daemon
|
/// active frontend's mirror would silently drift from daemon
|
||||||
/// state after every fallback / Key-path edit.
|
/// state after every fallback / Key-path edit.
|
||||||
pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
|
pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
|
||||||
|
/// T M4.5 L1 — bounded jump ring. Cross-file navigation
|
||||||
|
/// (`go-to-definition`, references, symbol jumps) pushes the
|
||||||
|
/// pre-jump `(BufferId, Position)` here before moving the cursor;
|
||||||
|
/// `M-,` (`jump_back`) pops the most recent entry and restores
|
||||||
|
/// it. Bounded at [`Self::JUMP_RING_CAP`]: the oldest entry is
|
||||||
|
/// evicted when full, so a long navigation session can't grow
|
||||||
|
/// this without limit. Entries naming a now-removed buffer are
|
||||||
|
/// skipped on pop (stale-handle safe, mirrors the registry's
|
||||||
|
/// `Missing` contract).
|
||||||
|
pub jump_ring: Vec<(BufferId, Position)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorCore {
|
impl EditorCore {
|
||||||
|
|
@ -156,11 +157,10 @@ impl EditorCore {
|
||||||
views,
|
views,
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
file_path: None,
|
|
||||||
file_meta: None,
|
|
||||||
minibuffer: Minibuffer::new(),
|
minibuffer: Minibuffer::new(),
|
||||||
active_frontend: FrontendId::LOCAL,
|
active_frontend: FrontendId::LOCAL,
|
||||||
pending_crdt_ops: Vec::new(),
|
pending_crdt_ops: Vec::new(),
|
||||||
|
jump_ring: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,6 +306,46 @@ impl EditorCore {
|
||||||
self.active_window().buffer_id
|
self.active_window().buffer_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path bound to the active window's buffer, if any. T M4.5 L1:
|
||||||
|
/// replaces the old `EditorCore.file_path` field — it now lives
|
||||||
|
/// per-buffer so cross-file navigation keeps each buffer's
|
||||||
|
/// identity straight.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_buffer_path(&self) -> Option<PathBuf> {
|
||||||
|
let id = self.active_buffer_id();
|
||||||
|
self.registry
|
||||||
|
.borrow()
|
||||||
|
.get(id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| b.file_path().map(Path::to_path_buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem metadata recorded for the active window's buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_file_meta(&self) -> Option<FileMeta> {
|
||||||
|
let id = self.active_buffer_id();
|
||||||
|
self.registry
|
||||||
|
.borrow()
|
||||||
|
.get(id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| b.file_meta().cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a path (and clear metadata) on a specific buffer. Used by
|
||||||
|
/// file open / `pmacs.buffer.from_file`.
|
||||||
|
pub fn set_buffer_path(&mut self, id: BufferId, path: Option<PathBuf>) {
|
||||||
|
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
|
||||||
|
b.set_file_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record filesystem metadata on a specific buffer.
|
||||||
|
pub fn set_buffer_meta(&mut self, id: BufferId, meta: Option<FileMeta>) {
|
||||||
|
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
|
||||||
|
b.set_file_meta(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cursor of the active window (compatibility shim for callers
|
/// Cursor of the active window (compatibility shim for callers
|
||||||
/// migrated from pre-M2.8 code).
|
/// migrated from pre-M2.8 code).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -370,6 +410,54 @@ impl EditorCore {
|
||||||
aw.goal_col = None;
|
aw.goal_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- jump ring (T M4.5 L1) ---------------------------------------------
|
||||||
|
|
||||||
|
/// Bound on [`Self::jump_ring`]. Large enough for a deep
|
||||||
|
/// cross-file dig (definition → definition → references …),
|
||||||
|
/// small enough that a stuck loop can't grow memory unbounded.
|
||||||
|
pub const JUMP_RING_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// Record the active window's current `(buffer, cursor)` as a
|
||||||
|
/// jump origin. Call this *before* moving the cursor on a
|
||||||
|
/// navigation action (go-to-definition, references, symbol jump)
|
||||||
|
/// so `M-,` can return here.
|
||||||
|
///
|
||||||
|
/// When the ring is at [`Self::JUMP_RING_CAP`], the oldest
|
||||||
|
/// origin is evicted (front drop) — the user keeps the most
|
||||||
|
/// recent trail, which is the one they're likely to unwind.
|
||||||
|
pub fn push_jump(&mut self) {
|
||||||
|
let entry = (self.active_buffer_id(), self.cursor());
|
||||||
|
if self.jump_ring.len() >= Self::JUMP_RING_CAP {
|
||||||
|
self.jump_ring.remove(0);
|
||||||
|
}
|
||||||
|
self.jump_ring.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the most recent jump origin and move there. Returns
|
||||||
|
/// `true` if a jump was performed.
|
||||||
|
///
|
||||||
|
/// Stale entries — a recorded buffer that has since been removed
|
||||||
|
/// from the registry — are skipped (the loop keeps popping until
|
||||||
|
/// it finds a live target or the ring empties), so a jump-back
|
||||||
|
/// never lands on a missing buffer. The restored cursor is
|
||||||
|
/// clamped to the (possibly now shorter) buffer length.
|
||||||
|
pub fn jump_back(&mut self) -> bool {
|
||||||
|
while let Some((bid, pos)) = self.jump_ring.pop() {
|
||||||
|
if !self.registry.borrow().contains(bid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let clamped = pos.min(self.active_buffer_len());
|
||||||
|
let aw = self.active_window_mut();
|
||||||
|
aw.cursor = clamped;
|
||||||
|
aw.goal_col = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// ---- editing primitives ------------------------------------------------
|
// ---- editing primitives ------------------------------------------------
|
||||||
|
|
||||||
/// Apply `op` to the active buffer; notify every window
|
/// Apply `op` to the active buffer; notify every window
|
||||||
|
|
@ -472,11 +560,11 @@ impl EditorCore {
|
||||||
/// `buffer.save` Lua command) use the return value to gate
|
/// `buffer.save` Lua command) use the return value to gate
|
||||||
/// `buffer.after-save` firing.
|
/// `buffer.after-save` firing.
|
||||||
pub fn save(&mut self) -> bool {
|
pub fn save(&mut self) -> bool {
|
||||||
let Some(path) = self.file_path.clone() else {
|
let id = self.active_buffer_id();
|
||||||
|
let Some(path) = self.active_buffer_path() else {
|
||||||
self.status = "no file (M1: open a file from argv)".into();
|
self.status = "no file (M1: open a file from argv)".into();
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let id = self.active_buffer_id();
|
|
||||||
let len_and_bytes = {
|
let len_and_bytes = {
|
||||||
let reg = self.registry.borrow();
|
let reg = self.registry.borrow();
|
||||||
let buffer = match reg.get(id) {
|
let buffer = match reg.get(id) {
|
||||||
|
|
@ -496,8 +584,8 @@ impl EditorCore {
|
||||||
let (_, content) = len_and_bytes;
|
let (_, content) = len_and_bytes;
|
||||||
match save_atomic(&path, &content) {
|
match save_atomic(&path, &content) {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
self.file_meta = Some(meta);
|
|
||||||
if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
|
if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
|
||||||
|
buf.set_file_meta(Some(meta));
|
||||||
buf.mark_clean();
|
buf.mark_clean();
|
||||||
}
|
}
|
||||||
self.status = format!("saved {}", path.display());
|
self.status = format!("saved {}", path.display());
|
||||||
|
|
@ -1783,4 +1871,82 @@ mod tests {
|
||||||
"F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
|
"F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- jump ring (T M4.5 L1) -----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_back_returns_false_on_empty_ring() {
|
||||||
|
let mut s = from_bytes(b"abc");
|
||||||
|
s.active_window_mut().cursor = 2;
|
||||||
|
assert!(!s.jump_back(), "empty ring must not move the cursor");
|
||||||
|
assert_eq!(s.cursor(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_then_jump_back_restores_cursor() {
|
||||||
|
let mut s = from_bytes(b"line one\nline two\nline three");
|
||||||
|
s.active_window_mut().cursor = 3;
|
||||||
|
s.push_jump();
|
||||||
|
s.active_window_mut().cursor = 20;
|
||||||
|
assert!(s.jump_back());
|
||||||
|
assert_eq!(s.cursor(), 3);
|
||||||
|
// Ring is now empty; a second pop is a no-op.
|
||||||
|
assert!(!s.jump_back());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_back_clamps_to_shortened_buffer() {
|
||||||
|
let mut s = from_bytes(b"abcdefghij");
|
||||||
|
s.active_window_mut().cursor = 9;
|
||||||
|
s.push_jump();
|
||||||
|
// Truncate the buffer so the recorded position is past EOF.
|
||||||
|
s.apply_active_edit(crate::buffer::EditOp::Delete {
|
||||||
|
range: Range::new(2, 10),
|
||||||
|
})
|
||||||
|
.expect("delete");
|
||||||
|
assert!(s.jump_back());
|
||||||
|
assert_eq!(
|
||||||
|
s.cursor(),
|
||||||
|
s.active_buffer_len(),
|
||||||
|
"stale position must clamp to the current buffer length"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_ring_is_bounded_and_evicts_oldest() {
|
||||||
|
let mut s = from_bytes(b"0123456789");
|
||||||
|
for i in 0..(EditorCore::JUMP_RING_CAP + 10) {
|
||||||
|
s.active_window_mut().cursor = (i % 10) as u64;
|
||||||
|
s.push_jump();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
s.jump_ring.len(),
|
||||||
|
EditorCore::JUMP_RING_CAP,
|
||||||
|
"ring must stay bounded at JUMP_RING_CAP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_back_skips_removed_buffer() {
|
||||||
|
let mut s = from_bytes(b"original");
|
||||||
|
// Record a jump on a second buffer, then remove that buffer.
|
||||||
|
let doomed = s.registry.borrow_mut().create_from_bytes("doomed", b"x");
|
||||||
|
s.switch_active_buffer(doomed).expect("switch");
|
||||||
|
s.active_window_mut().cursor = 1;
|
||||||
|
s.push_jump();
|
||||||
|
// Switch back and record a live origin too.
|
||||||
|
let original = *s.registry.borrow().ids().first().expect("original id");
|
||||||
|
s.switch_active_buffer(original).expect("switch back");
|
||||||
|
s.active_window_mut().cursor = 4;
|
||||||
|
s.push_jump();
|
||||||
|
s.active_window_mut().cursor = 0;
|
||||||
|
// Drop the doomed buffer: its ring entry is now stale.
|
||||||
|
s.registry.borrow_mut().remove(doomed).expect("remove");
|
||||||
|
// First pop lands on the live `original` origin.
|
||||||
|
assert!(s.jump_back());
|
||||||
|
assert_eq!(s.active_buffer_id(), original);
|
||||||
|
assert_eq!(s.cursor(), 4);
|
||||||
|
// Next pop would be the stale `doomed` entry — skipped, ring empties.
|
||||||
|
assert!(!s.jump_back());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2326,8 +2326,47 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
let mut core = core.borrow_mut();
|
let mut core = core.borrow_mut();
|
||||||
core.switch_active_buffer(id)
|
core.switch_active_buffer(id)
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
core.file_path = Some(path_buf);
|
core.set_buffer_path(id, Some(path_buf));
|
||||||
core.file_meta = Some(meta);
|
core.set_buffer_meta(id, Some(meta));
|
||||||
|
}
|
||||||
|
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||||
|
Ok(BufferIdLua(id))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// T M4.5 L1: find-or-open. If a buffer is already bound to
|
||||||
|
// `path`, switch to it (preserving unsaved edits — no
|
||||||
|
// reload); otherwise behave like `from_file`. The dedup is
|
||||||
|
// what makes cross-file navigation reuse an open file
|
||||||
|
// instead of spawning a duplicate buffer (SP-4 Gap A).
|
||||||
|
let reg = registry.clone();
|
||||||
|
buffer.set(
|
||||||
|
"find_or_open",
|
||||||
|
lua.create_function(move |lua, path: String| -> mlua::Result<BufferIdLua> {
|
||||||
|
let path_buf = std::path::PathBuf::from(&path);
|
||||||
|
if let Some(existing) = reg.borrow().find_by_path(&path_buf) {
|
||||||
|
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||||
|
core.borrow_mut()
|
||||||
|
.switch_active_buffer(existing)
|
||||||
|
.map_err(mlua::Error::external)?;
|
||||||
|
}
|
||||||
|
return Ok(BufferIdLua(existing));
|
||||||
|
}
|
||||||
|
let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| {
|
||||||
|
mlua::Error::external(std::io::Error::new(
|
||||||
|
source.kind(),
|
||||||
|
format!("failed to load {path}: {source}"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes);
|
||||||
|
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||||
|
let mut core = core.borrow_mut();
|
||||||
|
core.switch_active_buffer(id)
|
||||||
|
.map_err(mlua::Error::external)?;
|
||||||
|
core.set_buffer_path(id, Some(path_buf));
|
||||||
|
core.set_buffer_meta(id, Some(meta));
|
||||||
}
|
}
|
||||||
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||||
Ok(BufferIdLua(id))
|
Ok(BufferIdLua(id))
|
||||||
|
|
@ -6987,6 +7026,17 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||||
let lsp_mod = lua.create_table()?;
|
let lsp_mod = lua.create_table()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
// T M4.5 L1: decode a server-returned `file://` URI to a
|
||||||
|
// filesystem path so cross-file navigation can open it.
|
||||||
|
lsp_mod.set(
|
||||||
|
"path_for_uri",
|
||||||
|
lua.create_function(|_, uri: String| {
|
||||||
|
Ok(crate::project_index::uri_to_path(&uri).map(|p| p.display().to_string()))
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
|
|
@ -10465,6 +10515,25 @@ fn install_motion(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
// T M4.5 L1 — jump ring. Cross-file navigation records its
|
||||||
|
// origin via `push_jump`; `jump_back` (M-,) unwinds it.
|
||||||
|
{
|
||||||
|
let cc = core.clone();
|
||||||
|
editor.set(
|
||||||
|
"push_jump",
|
||||||
|
lua.create_function(move |_, ()| {
|
||||||
|
cc.borrow_mut().push_jump();
|
||||||
|
Ok(())
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let cc = core.clone();
|
||||||
|
editor.set(
|
||||||
|
"jump_back",
|
||||||
|
lua.create_function(move |_, ()| Ok(cc.borrow_mut().jump_back()))?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10587,7 +10656,7 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
||||||
"file_path",
|
"file_path",
|
||||||
lua.create_function(move |_, ()| {
|
lua.create_function(move |_, ()| {
|
||||||
let c = cc.borrow();
|
let c = cc.borrow();
|
||||||
Ok(c.file_path.as_ref().map(|p| p.display().to_string()))
|
Ok(c.active_buffer_path().map(|p| p.display().to_string()))
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1071,7 +1071,12 @@ fn lsp_position_from(range: Option<&serde_json::Value>) -> (u32, u32) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
/// Decode a `file://` URI into a filesystem path (authority dropped,
|
||||||
|
/// percent-decoded). `None` for non-`file://` URIs. T M4.5 L1: the
|
||||||
|
/// reverse of [`crate::lsp::path_to_file_uri`], reused by the Lua
|
||||||
|
/// surface (`pmacs.lsp.path_for_uri`) to turn server-returned
|
||||||
|
/// locations into openable files.
|
||||||
|
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
||||||
let rest = uri.strip_prefix("file://")?;
|
let rest = uri.strip_prefix("file://")?;
|
||||||
// Drop the optional authority component (`//host/path`) by
|
// Drop the optional authority component (`//host/path`) by
|
||||||
// skipping to the first '/' if the rest doesn't start with one.
|
// skipping to the first '/' if the rest doesn't start with one.
|
||||||
|
|
|
||||||
|
|
@ -270,8 +270,8 @@ impl SemanticRenderState {
|
||||||
// `path_to_file_uri` reproduces that exact key (the Lua
|
// `path_to_file_uri` reproduces that exact key (the Lua
|
||||||
// `file_uri_for` is byte-identical). A buffer with no file
|
// `file_uri_for` is byte-identical). A buffer with no file
|
||||||
// path, or no diagnostics under its URI, contributes nothing.
|
// path, or no diagnostics under its URI, contributes nothing.
|
||||||
if let Some(path) = core.file_path.as_ref() {
|
if let Some(path) = core.active_buffer_path() {
|
||||||
let uri = crate::lsp::path_to_file_uri(path);
|
let uri = crate::lsp::path_to_file_uri(&path);
|
||||||
let diags = {
|
let diags = {
|
||||||
let store = state.lsp_manager.borrow().diag_store();
|
let store = state.lsp_manager.borrow().diag_store();
|
||||||
let guard = store.lock().expect("diag store mutex poisoned");
|
let guard = store.lock().expect("diag store mutex poisoned");
|
||||||
|
|
@ -621,7 +621,7 @@ mod tests {
|
||||||
bytes: b"abc\nde",
|
bytes: b"abc\nde",
|
||||||
})
|
})
|
||||||
.expect("seed buffer text");
|
.expect("seed buffer text");
|
||||||
core.file_path = Some(std::path::PathBuf::from("/tmp/m114.rs"));
|
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/m114.rs")));
|
||||||
drop(core);
|
drop(core);
|
||||||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
|
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
|
||||||
let store = state.lsp_manager.borrow().diag_store();
|
let store = state.lsp_manager.borrow().diag_store();
|
||||||
|
|
|
||||||
|
|
@ -3304,6 +3304,126 @@ fn m4_12_lua_surface_drives_definition_and_formatting() {
|
||||||
assert_eq!(fmt_first_text, "");
|
assert_eq!(fmt_first_text, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// T M4.5 L1 — cross-file go-to-definition end to end through the
|
||||||
|
/// default bundle. The `defenv` fake returns a definition whose URI
|
||||||
|
/// names a *different* file; `pmacs.lsp.go_to_definition` must decode
|
||||||
|
/// it (`path_for_uri`), record the jump origin (`push_jump`),
|
||||||
|
/// open-or-reuse that buffer (`find_or_open` — SP-4 Gap A), and
|
||||||
|
/// reposition the cursor. `M-,` (`jump_back`) then returns to the
|
||||||
|
/// originating file at the originating position.
|
||||||
|
#[test]
|
||||||
|
fn m4_12_cross_file_go_to_definition_and_jump_back() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let a_path = dir.path().join("a.rs");
|
||||||
|
let b_path = dir.path().join("b.rs");
|
||||||
|
std::fs::write(&a_path, b"fn main() { helper(); }\n").expect("write a");
|
||||||
|
// line 0,1 padding so the fake's line-2 target is in range.
|
||||||
|
std::fs::write(&b_path, b"// b\n// b\nfn helper() {}\n").expect("write b");
|
||||||
|
let a_disp = a_path.display().to_string();
|
||||||
|
let b_disp = b_path.display().to_string();
|
||||||
|
let b_uri = format!("file://{b_disp}");
|
||||||
|
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
let fake = fake_lsp_path();
|
||||||
|
|
||||||
|
// Point the default `rust` server at the fake, in `defenv` mode,
|
||||||
|
// with the cross-file target URI threaded through the spawn env
|
||||||
|
// (exercises the new `ensure_server` env passthrough too).
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(format!(
|
||||||
|
"pmacs.lsp.config.rust = {{
|
||||||
|
command = '{fake}',
|
||||||
|
env = {{
|
||||||
|
PMACS_FAKE_LSP_MODE = 'defenv',
|
||||||
|
PMACS_FAKE_LSP_DEF_URI = '{b_uri}',
|
||||||
|
}},
|
||||||
|
}}"
|
||||||
|
))
|
||||||
|
.exec()
|
||||||
|
.expect("override rust config");
|
||||||
|
|
||||||
|
// Open the origin file: path-binds the buffer and fires
|
||||||
|
// `buffer.after-load`, which attaches & spawns the fake.
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
|
||||||
|
.exec()
|
||||||
|
.expect("open a.rs");
|
||||||
|
|
||||||
|
// Pump until the attached server is initialized.
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(
|
||||||
|
&mut state,
|
||||||
|
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
|
||||||
|
if r.state and r.state.kind=='initialized' then return true end \
|
||||||
|
end return false end)()",
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
"fake never initialized"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity: we start on a.rs.
|
||||||
|
let start_path: Option<String> = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return pmacs.editor.file_path()")
|
||||||
|
.eval()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(start_path.as_deref(), Some(a_disp.as_str()));
|
||||||
|
|
||||||
|
// Invoke the command; the coroutine awaits the response.
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("pmacs.lsp.go_to_definition()")
|
||||||
|
.exec()
|
||||||
|
.expect("invoke go-to-definition");
|
||||||
|
|
||||||
|
// Completion signal: the active buffer becomes b.rs.
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(
|
||||||
|
&mut state,
|
||||||
|
&format!("pmacs.editor.file_path() == '{b_disp}'"),
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
"cross-file jump never landed on b.rs"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cursor sits on the fake's line-2 target in the new buffer.
|
||||||
|
let line: i64 = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return pmacs.editor.cursor_line()")
|
||||||
|
.eval()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line, 2, "cursor should be on b.rs line 2 (0-based)");
|
||||||
|
|
||||||
|
// M-, returns to the origin file.
|
||||||
|
let jumped: bool = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return pmacs.editor.jump_back()")
|
||||||
|
.eval()
|
||||||
|
.unwrap();
|
||||||
|
assert!(jumped, "jump_back should report a successful pop");
|
||||||
|
let back: Option<String> = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return pmacs.editor.file_path()")
|
||||||
|
.eval()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
back.as_deref(),
|
||||||
|
Some(a_disp.as_str()),
|
||||||
|
"jump_back must return to the originating file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
|
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
|
||||||
/// hooks are defined, the namespace tables exist, the user-facing
|
/// hooks are defined, the namespace tables exist, the user-facing
|
||||||
/// commands are registered with the command registry, and the default
|
/// commands are registered with the command registry, and the default
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue