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,
|
||||
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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue