diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 042c035..d02ca77 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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" } diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 7f1cdad..89a05fa 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -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, diff --git a/src/buffer.rs b/src/buffer.rs index f82ba49..5dfd44c 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -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, /// Redo stack. Cleared by any forward edit. redo: Vec, + /// 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, + /// Filesystem metadata captured at the last successful load/save, + /// used for external-change detection. Relocated alongside + /// [`Self::file_path`]. + file_meta: Option, /// 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) { + 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) { + self.file_meta = meta; + } + /// Construct an empty CRDT-backed buffer. /// /// `peer_id` identifies this frontend's edits in the CRDT op diff --git a/src/buffer_registry.rs b/src/buffer_registry.rs index 00620ad..1b5249c 100644 --- a/src/buffer_registry.rs +++ b/src/buffer_registry.rs @@ -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 { + 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] diff --git a/src/editor.rs b/src/editor.rs index 0971c1e..8f8b3c6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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, ""); } diff --git a/src/editor_core.rs b/src/editor_core.rs index 52dda1c..1ea8cc3 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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, - /// File metadata at the last load or save. - pub file_meta: Option, /// 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 { + 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 { + 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) { + 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) { + 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()); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index dae2c92..4a8a1ae 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -2326,8 +2326,47 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { + 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::() { + 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::() { + 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())) })?, )?; } diff --git a/src/project_index.rs b/src/project_index.rs index bc5a518..63831bb 100644 --- a/src/project_index.rs +++ b/src/project_index.rs @@ -1071,7 +1071,12 @@ fn lsp_position_from(range: Option<&serde_json::Value>) -> (u32, u32) { }) } -fn uri_to_path(uri: &str) -> Option { +/// 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 { 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. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 2e26235..da2c007 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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(); diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 28e1cad..61dde86 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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 = 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 = 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