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:
Levi Neuwirth 2026-05-19 09:53:29 -04:00
parent a1a2369d3a
commit f7267cd720
10 changed files with 492 additions and 29 deletions

View File

@ -162,6 +162,7 @@ local function ensure_server(language)
language_id = language,
command = cfg.command,
args = cfg.args or {},
env = cfg.env,
init_options = cfg.init_options,
settings = cfg.settings,
})
@ -379,11 +380,37 @@ function pmacs.lsp.go_to_definition()
end
local first = locs[1]
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)
pmacs.editor.set_status(string.format(
"LSP: definition at %d:%d", first.line + 1, first.col + 1))
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
@ -571,12 +598,26 @@ pmacs.command.define {
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
-- 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
-- init.lua.
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.jump-back" }
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 s", command = "lsp.signature-help" }

View File

@ -313,6 +313,19 @@ fn main() {
serde_json::Value::from(format!("pos:{ch}")),
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 {
(
uri,

View File

@ -26,8 +26,10 @@
//! moves its view list out of `self` before iterating, so the views can
//! observe `&Buffer` while the buffer's own `&mut self` is held.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::file_io::FileMeta;
use crate::rope::{Edit, Position, Range, Rope, RopeError};
use crate::view::{InterceptContext, View};
@ -209,6 +211,16 @@ pub struct Buffer {
undo: Vec<UndoEntry>,
/// Redo stack. Cleared by any forward edit.
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).
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
@ -267,12 +279,36 @@ impl Buffer {
next_mark_id: 0,
undo: Vec::new(),
redo: Vec::new(),
file_path: None,
file_meta: None,
editing_in_progress: false,
#[cfg(feature = "crdt")]
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.
///
/// `peer_id` identifies this frontend's edits in the CRDT op

View File

@ -160,6 +160,19 @@ impl BufferRegistry {
.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
/// operations.
#[must_use]

View File

@ -387,8 +387,8 @@ impl EditorState {
.create_from_bytes(display_name, &bytes);
state.replace_active_buffer(new_id);
let mut core = state.core.borrow_mut();
core.file_path = Some(path);
core.file_meta = Some(meta);
core.set_buffer_path(new_id, Some(path));
core.set_buffer_meta(new_id, Some(meta));
fire_after_load = true;
Ok(())
}
@ -396,7 +396,7 @@ impl EditorState {
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
state.replace_active_buffer(new_id);
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();
Ok(())
}
@ -1838,8 +1838,8 @@ mod tests {
let s = EditorState::open(path.clone()).expect("must succeed");
let core = s.core.borrow();
assert!(core.active_buffer_len() == 0);
assert_eq!(core.file_path.as_deref(), Some(path.as_path()));
assert!(core.file_meta.is_none());
assert_eq!(core.active_buffer_path().as_deref(), Some(path.as_path()));
assert!(core.active_file_meta().is_none());
assert_eq!(core.status, "[new file]");
}
@ -1851,7 +1851,7 @@ mod tests {
let s = EditorState::open(path.clone()).expect("must succeed");
let core = s.core.borrow();
assert_eq!(core.active_buffer_len(), 5);
assert!(core.file_meta.is_some());
assert!(core.active_file_meta().is_some());
assert_eq!(core.status, "");
}

View File

@ -22,7 +22,7 @@
//! caches synchronized.
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::file_io::{FileMeta, save_atomic};
@ -83,15 +83,6 @@ pub struct EditorCore {
pub status: String,
/// True iff the editor should exit at the next iteration.
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).
pub minibuffer: Minibuffer,
/// 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
/// state after every fallback / Key-path edit.
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 {
@ -156,11 +157,10 @@ impl EditorCore {
views,
status: String::new(),
quit: false,
file_path: None,
file_meta: None,
minibuffer: Minibuffer::new(),
active_frontend: FrontendId::LOCAL,
pending_crdt_ops: Vec::new(),
jump_ring: Vec::new(),
}
}
@ -306,6 +306,46 @@ impl EditorCore {
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
/// migrated from pre-M2.8 code).
#[must_use]
@ -370,6 +410,54 @@ impl EditorCore {
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 ------------------------------------------------
/// 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.after-save` firing.
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();
return false;
};
let id = self.active_buffer_id();
let len_and_bytes = {
let reg = self.registry.borrow();
let buffer = match reg.get(id) {
@ -496,8 +584,8 @@ impl EditorCore {
let (_, content) = len_and_bytes;
match save_atomic(&path, &content) {
Ok(meta) => {
self.file_meta = Some(meta);
if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
buf.set_file_meta(Some(meta));
buf.mark_clean();
}
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"
);
}
// ---- 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());
}
}

View File

@ -2326,8 +2326,47 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
let mut core = core.borrow_mut();
core.switch_active_buffer(id)
.map_err(mlua::Error::external)?;
core.file_path = Some(path_buf);
core.file_meta = Some(meta);
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());
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());
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 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();
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(())
}
@ -10587,7 +10656,7 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
"file_path",
lua.create_function(move |_, ()| {
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()))
})?,
)?;
}

View File

@ -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://")?;
// Drop the optional authority component (`//host/path`) by
// skipping to the first '/' if the rest doesn't start with one.

View File

@ -270,8 +270,8 @@ impl SemanticRenderState {
// `path_to_file_uri` reproduces that exact key (the Lua
// `file_uri_for` is byte-identical). A buffer with no file
// path, or no diagnostics under its URI, contributes nothing.
if let Some(path) = core.file_path.as_ref() {
let uri = crate::lsp::path_to_file_uri(path);
if let Some(path) = core.active_buffer_path() {
let uri = crate::lsp::path_to_file_uri(&path);
let diags = {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
@ -621,7 +621,7 @@ mod tests {
bytes: b"abc\nde",
})
.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);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
let store = state.lsp_manager.borrow().diag_store();

View File

@ -3304,6 +3304,126 @@ fn m4_12_lua_surface_drives_definition_and_formatting() {
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
/// hooks are defined, the namespace tables exist, the user-facing
/// commands are registered with the command registry, and the default