M4.6 — diag.next / diag.previous commands bound to M-g n / M-g p (task #23) (#51)

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-21 21:54:54 +00:00 committed by GitHub
parent c414954820
commit 304e54089f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 108 additions and 0 deletions

View File

@ -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" }

View File

@ -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<String> = 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<String> = 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