From 304e54089f073899bd164245a598eeeaa23b834a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 21 May 2026 21:54:54 +0000 Subject: [PATCH] =?UTF-8?q?M4.6=20=E2=80=94=20diag.next=20/=20diag.previou?= =?UTF-8?q?s=20commands=20bound=20to=20M-g=20n=20/=20M-g=20p=20(task=20#23?= =?UTF-8?q?)=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds diagnostic navigation to the TUI/editor surface. Reuses the existing `pmacs.diag.next` / `previous` walkers (which already wrap around) and the cross-file jump ring so `M-,` returns from a diagnostic jump just like an LSP definition jump. Surface: * `pmacs.command.define { name = "diag.next" / "diag.previous" }` * `pmacs.keymap.bind { sequence = "M-g n" / "M-g p" }` — Emacs's `next-error` / `previous-error` chord. The command walks the diag store for the active buffer's attached URI, falls back to a status-line message ("no LSP server" / "no diagnostics in buffer") rather than faulting when there's nothing to jump to. On a hit it pushes the jump ring, moves the cursor via `pmacs.editor` motion primitives (so every overlay observer sees the navigation), and sets a status line of the form `diag (warning): ...`. Test verifies the commands are registered, bindings exist, and the no-server status path lands. Co-authored-by: Claude Opus 4.7 --- builtin/runtime/lsp.lua | 45 +++++++++++++++++++++++++++++ tests/m4_acceptance.rs | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) 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