diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 3e72069..03910ce 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1503,3 +1503,48 @@ pmacs.keymap.bind { scope = "global", sequence = "C-c y", command = "lsp.semanti 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 f", command = "lsp.format-buffer" } + +-- Diagnostic navigation (task #23, M4.6 surface) ----------------------------- +-- +-- Emacs's `M-g n` / `M-g p` jump between compile/next-error locations. We +-- reuse the chord for LSP diagnostics: walk the diag store for the active +-- buffer's URI and move the cursor to the next/previous diagnostic. Wraps +-- around (`pmacs.diag.next`'s default), so repeated taps cycle. +local function navigate_diagnostic(direction) + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("diag: no LSP server for active buffer") + return + end + local line = pmacs.editor.cursor_line() + local col = pmacs.editor.cursor_col() + local found + if direction == "next" then + found = pmacs.diag.next(rec.uri, line, col) + else + found = pmacs.diag.previous(rec.uri, line, col) + end + if not found then + pmacs.editor.set_status("diag: no diagnostics in buffer") + return + end + pmacs.editor.push_jump() + move_active_cursor_to(found.start_line, found.start_col) + pmacs.editor.set_status(string.format("diag (%s): %s", + found.severity or "?", found.message or "")) +end + +pmacs.command.define { + name = "diag.next", + description = "Jump to the next diagnostic in the active buffer (wraps).", + fn = function() navigate_diagnostic("next") end, +} + +pmacs.command.define { + name = "diag.previous", + description = "Jump to the previous diagnostic in the active buffer (wraps).", + fn = function() navigate_diagnostic("previous") end, +} + +pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "diag.next" } +pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "diag.previous" } diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 82c9e12..868b29a 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1754,6 +1754,69 @@ fn m4_6_lua_surface_reads_diagnostics() { ); } +/// Task #23 follow-up: `lsp.lua` registers `diag.next` / `diag.previous` +/// commands and binds them under `M-g n` / `M-g p`. The bindings cover +/// the most-common Emacs convention for navigate-to-next-error. +#[test] +fn m4_6_diag_navigate_commands_and_bindings_are_registered() { + use pmacs::editor::EditorState; + + let state = EditorState::new(); + let lua = state.lua_host.lua(); + + let commands: Vec = lua + .load("return pmacs.command.list()") + .eval() + .expect("command.list"); + assert!( + commands.iter().any(|c| c == "diag.next"), + "diag.next must be registered; got: {commands:?}" + ); + assert!( + commands.iter().any(|c| c == "diag.previous"), + "diag.previous must be registered; got: {commands:?}" + ); + + // Each binding is a row `{ scope, sequence, command }`. Project + // those into a sortable string set so we can assert specific + // bindings exist regardless of insertion order. + let bindings: Vec = lua + .load( + r" + local out = {} + for _, e in ipairs(pmacs.keymap.list()) do + table.insert(out, e.sequence .. '=>' .. e.command) + end + return out + ", + ) + .eval() + .expect("keymap.list"); + assert!( + bindings.iter().any(|b| b == "M-g n=>diag.next"), + "M-g n must bind to diag.next; got: {bindings:?}" + ); + assert!( + bindings.iter().any(|b| b == "M-g p=>diag.previous"), + "M-g p must bind to diag.previous; got: {bindings:?}" + ); + + // Without an LSP attachment, the command should surface a status + // message rather than fault or jump anywhere. (The scratch buffer + // has no file path → no URI → `attached_for_active` returns nil.) + state + .lua_host + .lua() + .load("pmacs.command.invoke('diag.next')") + .exec() + .expect("diag.next invoke"); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("no LSP server") || status.contains("no diagnostics"), + "expected a diag-related status, got: {status:?}" + ); +} + /// Task #23: `pmacs.diag._attach_view` pushes a `DiagnosticView` onto /// the active window's overlay stack so the TUI grid renderer paints /// diagnostic underlines. Verifies the binding lands and that the