From 74ff468e74b1010ae15a1f493ac6dd33dd87c7f9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 21:30:52 -0400 Subject: [PATCH 1/3] feat(panels): outline, code-action picker, hover-doc (Arc 1b phase 2) Pure Lua on the phase-1 substrate (framing Q#P5). Outline: lsp.document-symbols (C-c o) opens *outline* -- the store's FLAT symbol rows indent by their depth field with an LSP SymbolKind tag; RET pushes the jump ring, restores the source buffer, and moves to the symbol (M-, returns to the outline row, the references-panel semantics). Code actions: lsp.code-actions (C-c a) applies a single action directly (previous behavior, now correct instead of lucky) and opens the minibuffer dropdown when several are available -- 'N: title' candidates; a bare typed index also accepts. The apply branch is extracted as apply_code_action, shared by both paths. The m4_14/m4_15 acceptance tests (written against blind-first-apply; the fake LSP returns two actions) now drive the picker: pump until the prompt is live, type '1', RET -- same command-only action as before. Hover doc: new lsp.hover-doc (C-c H) renders the full multi-line hover contents into a non-visitable *lsp-help* panel; lsp.hover (C-c h) keeps its one-line echo-area summary. Co-Authored-By: Claude Fable 5 --- builtin/runtime/lsp.lua | 176 ++++++++++++++++++++++++++++++++-------- tests/m4_acceptance.rs | 36 ++++++++ 2 files changed, 180 insertions(+), 32 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d2b171b..75be46b 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1234,6 +1234,15 @@ function pmacs.lsp.go_to_definition() end) end +-- LSP SymbolKind (1..=26) -> short outline tag (Arc 1b phase 2). +local SYMBOL_KIND_TAGS = { + "file", "module", "namespace", "package", "class", "method", + "property", "field", "constructor", "enum", "interface", "function", + "variable", "constant", "string", "number", "boolean", "array", + "object", "key", "null", "enum-member", "struct", "event", + "operator", "type-parameter", +} + -- Visit one LSP location (Arc 1b): the SP-4 cross-file template --- -- jump ring, find-or-open, cursor walk. Same-buffer hits skip the -- open. Shared by the references panel (and the outline in phase 2). @@ -1335,14 +1344,38 @@ function pmacs.lsp.document_symbols() pmacs.editor.set_status("LSP: no symbols") return end - -- v1 modeline summary (count + first symbol); a structured - -- outline buffer driven off this store is future UX work, like - -- the references list and hover panel. - local first = syms[1] + -- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive + -- FLAT with a `depth` field --- indent, don't recurse. RET + -- visits (jump ring: M-, returns to the outline row); q restores. + local source_buf = rec.buffer + local rows = {} + for _, sym in ipairs(syms) do + local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol" + rows[#rows + 1] = { + text = string.format( + "%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), + item = sym, + } + end + pmacs.listview.open { + name = "*outline*", + header = string.format( + "%d symbol%s RET visit n/p move q quit", + #syms, (#syms == 1 and "" or "s")), + rows = rows, + on_visit = function(sym) + pmacs.editor.push_jump() + local okv = pcall(pmacs.window.switch_buffer, source_buf) + if not okv then + pmacs.editor.jump_back() + pmacs.editor.set_status("LSP: outline source buffer is gone") + return + end + move_active_cursor_to(sym.line, sym.col) + end, + } pmacs.editor.set_status(string.format( - "LSP: %d symbol%s; first '%s' at %d:%d", - #syms, (#syms == 1 and "" or "s"), - first.name, first.line + 1, first.col + 1)) + "LSP: %d symbol%s", #syms, (#syms == 1 and "" or "s"))) end) end @@ -1569,6 +1602,38 @@ end -- the pump installed below). A selection UI over multiple actions is -- future UX work, like the references list and hover panel — v1 -- acts on the first and reports how many were offered. +-- Apply one code action (Arc 1b phase 2: shared by the direct path +-- and the picker). Runs its WorkspaceEdit inline and/or awaits its +-- executeCommand, then reports what happened. Must run inside a +-- `pmacs.async` coroutine. +local function apply_code_action(rec, act) + local bits = {} + if act.has_edit then + local n, files, res = apply_workspace_edit(act.edit) + if not n then + pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files)) + return + end + local b = string.format("%d edit(s) / %d file(s)", n, files) + if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end + table.insert(bits, b) + end + if act.command then + local ok, cerr = pcall(function() + pmacs.lsp.request_execute_command( + rec.server, act.command.command, act.command.arguments):await() + end) + if not ok then + pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) + return + end + table.insert(bits, "ran '" .. act.command.command .. "'") + end + local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" + pmacs.editor.set_status(string.format( + "LSP: code action '%s'%s", act.title, detail)) +end + function pmacs.lsp.code_actions() local rec = attached_for_active() if not rec then @@ -1592,33 +1657,35 @@ function pmacs.lsp.code_actions() pmacs.editor.set_status("LSP: no code actions") return end - local first = acts[1] - local bits = {} - if first.has_edit then - local n, files, res = apply_workspace_edit(first.edit) - if not n then - pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files)) - return - end - local b = string.format("%d edit(s) / %d file(s)", n, files) - if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end - table.insert(bits, b) + -- Arc 1b phase 2: one action applies directly (today's behavior, + -- now correct instead of lucky); several open the minibuffer + -- dropdown so the USER picks — v1 applied acts[1] blind. + if #acts == 1 then + apply_code_action(rec, acts[1]) + return end - if first.command then - local ok2, cerr = pcall(function() - pmacs.lsp.request_execute_command( - rec.server, first.command.command, first.command.arguments):await() - end) - if not ok2 then - pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) - return - end - table.insert(bits, "ran '" .. first.command.command .. "'") + local labels = {} + for i, a in ipairs(acts) do + labels[i] = string.format("%d: %s", i, a.title) end - local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" - pmacs.editor.set_status(string.format( - "LSP: code action '%s'%s (%d available)", - first.title, detail, #acts)) + pmacs.minibuffer.read { + prompt = string.format("Code action (%d): ", #acts), + source = function() return labels end, + on_accept = function(choice) + if not choice or choice == "" then return end + -- Accept both the completed candidate ("2: Inline fix") + -- and a bare typed index ("2"). + local idx = tonumber(choice:match("^(%d+)")) + local act = idx and acts[idx] + if not act then + pmacs.editor.set_status("LSP: no such code action") + return + end + pmacs.async(function() + apply_code_action(rec, act) + end) + end, + } end) end @@ -1652,6 +1719,44 @@ function pmacs.lsp.hover_at_cursor() end) end +-- Arc 1b phase 2: the full (multi-line) hover body in a *lsp-help* +-- panel --- `lsp.hover` keeps its one-line echo-area summary; this is +-- the "show me everything" companion. Rows are non-visitable +-- (item = nil, so RET is a no-op); q restores the source buffer. +function pmacs.lsp.hover_doc() + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("LSP: no server for active buffer") + return + end + local line = pmacs.editor.cursor_line() + local col = pmacs.editor.cursor_col() + pmacs.hover.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_hover(rec.server, rec.uri, line, col):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local hover = pmacs.hover.current(rec.server, rec.uri) + if not hover or not hover.contents or hover.contents == "" then + pmacs.editor.set_status("LSP: no hover info") + return + end + local rows = {} + for l in (hover.contents .. "\n"):gmatch("(.-)\n") do + rows[#rows + 1] = { text = l } + end + pmacs.listview.open { + name = "*lsp-help*", + header = "hover documentation q quit", + rows = rows, + } + end) +end + function pmacs.lsp.signature_help_at_cursor() local rec = attached_for_active() if not rec then @@ -1693,6 +1798,12 @@ pmacs.command.define { fn = pmacs.lsp.format_buffer, } +pmacs.command.define { + name = "lsp.hover-doc", + description = "Show the full hover documentation in a *lsp-help* panel.", + fn = pmacs.lsp.hover_doc, +} + pmacs.command.define { name = "lsp.hover", description = "Surface the hover documentation for the symbol under the cursor.", @@ -1767,6 +1878,7 @@ pmacs.keymap.bind { scope = "global", sequence = "C-c a", command = "lsp.code-ac pmacs.keymap.bind { scope = "global", sequence = "C-c i", command = "lsp.inlay-hints" } pmacs.keymap.bind { scope = "global", sequence = "C-c y", command = "lsp.semantic-tokens" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } +pmacs.keymap.bind { scope = "global", sequence = "C-c H", command = "lsp.hover-doc" } 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" } diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index bfa07d4..dd75c4c 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3850,6 +3850,24 @@ fn m4_14_code_action_command_drives_apply_edit() { .exec() .expect("invoke code actions"); + // Arc 1b phase 2: with two actions available, `code_actions` now + // opens the minibuffer picker instead of blind-applying the + // first. Pump until the prompt is live, then pick action 1 (the + // command-only action, preserving this test's original subject) + // by typed index + RET. + assert!( + pump_lua_flag(&mut state, "#pmacs.minibuffer.candidates() > 0", 5), + "code-action picker never opened" + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // The applyEdit pump runs on the async tick; it applies the // server's out-of-band edit, turning line 1 "___zzz" -> "ED2zzz". assert!( @@ -3944,6 +3962,24 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { .exec() .expect("invoke code actions"); + // Arc 1b phase 2: with two actions available, `code_actions` now + // opens the minibuffer picker instead of blind-applying the + // first. Pump until the prompt is live, then pick action 1 (the + // command-only action, preserving this test's original subject) + // by typed index + RET. + assert!( + pump_lua_flag(&mut state, "#pmacs.minibuffer.candidates() > 0", 5), + "code-action picker never opened" + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // Completion signal: the created file exists on disk. Tick the // full frame order (processes → lsp → async) so the // executeCommand round-trip, the server-initiated applyEdit From 3bedb61cf85b9b5f1b131c758e42f3f8d19971fc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 22:13:09 -0400 Subject: [PATCH 2/3] test(panels): outline + hover-doc acceptance against the fake LSP PR #95 review P3: the new panel paths had no direct coverage. Two end-to-end tests against the fake server's canned responses: - outline_panel_opens_visits_and_restores: depth-indented rows with kind tags, n + RET visits inner's selectionRange (3,7) in the source buffer, M-, returns to the outline row, q restores. - hover_doc_panel_shows_full_contents_via_binding: driven through the REAL C-c H chord (Char('H') + SHIFT through the dispatcher) -- doubling as the shifted-letter binding's parse check, which passes -- multi-line contents render, q restores. Co-Authored-By: Claude Fable 5 --- tests/m4_acceptance.rs | 219 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index dd75c4c..3948013 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -6099,3 +6099,222 @@ fn m4_5_symbols_and_highlight_round_trip() { assert_eq!(g("_dh1k"), "2", "explicit DocumentHighlightKind (Read)"); assert_eq!(g("_dh2k"), "1", "absent kind defaults to Text(1)"); } + +// =========================================================================== +// Arc 1b phase 2 --- LSP panels (outline, hover-doc) end-to-end +// =========================================================================== + +/// The *outline* panel end-to-end against the fake server's +/// hierarchical documentSymbol response ("Outer" class > "inner" +/// method): open, depth-indented rows, RET jump-ring visit to the +/// symbol's selectionRange, M-, back to the outline row, q restore. +#[test] +fn outline_panel_opens_visits_and_restores() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + std::fs::write( + &a_path, + b"l0\nl1\nl2\nl3 inner here\nl4\nl5\nl6\nl7\nl8\nl9\n", + ) + .expect("write a"); + let a_disp = a_path.display().to_string(); + + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + state + .lua_host + .lua() + .load(format!("pmacs.lsp.config.rust = {{ command = '{fake}' }}")) + .exec() + .expect("override rust config"); + state + .lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .exec() + .expect("open a.rs"); + 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" + ); + + state + .lua_host + .lua() + .load("pmacs.lsp.document_symbols()") + .exec() + .expect("invoke document symbols"); + assert!( + pump_lua_flag( + &mut state, + "pmacs.describe.buffer(pmacs.window.buffer()).name == '*outline*'", + 5, + ), + "the outline panel never opened" + ); + + let text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .expect("outline text"); + assert!( + text.contains("Outer [class]"), + "top-level symbol row with kind tag; got {text:?}" + ); + assert!( + text.contains("\n inner [method]"), + "depth-1 symbol indents two spaces; got {text:?}" + ); + + // n moves to the second row (inner); RET visits its + // selectionRange (line 3, col 7 in the fake's response). + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let (name, line, col): (String, i64, i64) = state + .lua_host + .lua() + .load( + r" + local d = pmacs.describe.buffer(pmacs.window.buffer()) + return d.name, pmacs.editor.cursor_line(), pmacs.editor.cursor_col() + ", + ) + .eval() + .expect("post-visit probe"); + assert!(name.ends_with("a.rs"), "RET returns to the source buffer"); + assert_eq!( + (line, col), + (3, 7), + "cursor lands on inner's selectionRange" + ); + + // M-, returns to the outline row (the visit pushed the jump ring + // from the panel). + state + .lua_host + .lua() + .load("pmacs.editor.jump_back()") + .exec() + .expect("jump back"); + let name: String = state + .lua_host + .lua() + .load("return pmacs.describe.buffer(pmacs.window.buffer()).name") + .eval() + .expect("post-jump-back probe"); + assert_eq!(name, "*outline*", "M-, returns to the outline panel"); + + // q restores the source buffer. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + let name: String = state + .lua_host + .lua() + .load("return pmacs.describe.buffer(pmacs.window.buffer()).name") + .eval() + .expect("post-q probe"); + assert!(name.ends_with("a.rs"), "q restores the source buffer"); +} + +/// The *lsp-help* panel end-to-end, driven through the real `C-c H` +/// keybinding (shifted-letter chord --- this test is also the +/// binding's parse check): full multi-line hover contents render, +/// q restores. +#[test] +fn hover_doc_panel_shows_full_contents_via_binding() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("h.rs"); + std::fs::write(&a_path, b"fn main() {}\n").expect("write h"); + let a_disp = a_path.display().to_string(); + + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + state + .lua_host + .lua() + .load(format!("pmacs.lsp.config.rust = {{ command = '{fake}' }}")) + .exec() + .expect("override rust config"); + state + .lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .exec() + .expect("open h.rs"); + 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" + ); + + // The real chord: C-c, then Shift+h (terminals deliver uppercase + // Char('H') with the SHIFT modifier set). + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT), + ); + assert!( + pump_lua_flag( + &mut state, + "pmacs.describe.buffer(pmacs.window.buffer()).name == '*lsp-help*'", + 5, + ), + "C-c H never opened the hover panel (chord parse or binding gap)" + ); + + let text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .expect("hover panel text"); + assert!( + text.contains("Synthetic hover content"), + "the full hover body renders; got {text:?}" + ); + assert!( + text.contains("# pmacs-fake-lsp"), + "multi-line contents keep their first line; got {text:?}" + ); + + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + let name: String = state + .lua_host + .lua() + .load("return pmacs.describe.buffer(pmacs.window.buffer()).name") + .eval() + .expect("post-q probe"); + assert!(name.ends_with("h.rs"), "q restores the source buffer"); +} From 99b8743f40edec49a48c299d6527034d4c092b27 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 22:16:49 -0400 Subject: [PATCH 3/3] style(test): factor fake-LSP bootstrap out of the panel tests Fixes the too-many-lines clippy deny the previous commit shipped with (masked locally by a swallowed exit code in the gate chain); the shared open_against_fake helper also de-duplicates the two new tests. Co-Authored-By: Claude Fable 5 --- tests/m4_acceptance.rs | 72 +++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 3948013..91ed286 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -6104,24 +6104,10 @@ fn m4_5_symbols_and_highlight_round_trip() { // Arc 1b phase 2 --- LSP panels (outline, hover-doc) end-to-end // =========================================================================== -/// The *outline* panel end-to-end against the fake server's -/// hierarchical documentSymbol response ("Outer" class > "inner" -/// method): open, depth-indented rows, RET jump-ring visit to the -/// symbol's selectionRange, M-, back to the outline row, q restore. -#[test] -fn outline_panel_opens_visits_and_restores() { - use pmacs::editor::EditorState; - - let dir = tempfile::tempdir().expect("tempdir"); - let a_path = dir.path().join("a.rs"); - std::fs::write( - &a_path, - b"l0\nl1\nl2\nl3 inner here\nl4\nl5\nl6\nl7\nl8\nl9\n", - ) - .expect("write a"); - let a_disp = a_path.display().to_string(); - - let mut state = EditorState::new(); +/// Open `path` against the fake server and wait for initialization +/// (shared bootstrap for the panel tests). +fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState { + let mut state = pmacs::editor::EditorState::new(); let fake = fake_lsp_path(); state .lua_host @@ -6132,9 +6118,9 @@ fn outline_panel_opens_visits_and_restores() { state .lua_host .lua() - .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .load(format!("pmacs.buffer.find_or_open('{}')", path.display())) .exec() - .expect("open a.rs"); + .expect("open file against fake"); assert!( pump_lua_flag( &mut state, @@ -6145,6 +6131,24 @@ fn outline_panel_opens_visits_and_restores() { ), "fake never initialized" ); + state +} + +/// The *outline* panel end-to-end against the fake server's +/// hierarchical documentSymbol response ("Outer" class > "inner" +/// method): open, depth-indented rows, RET jump-ring visit to the +/// symbol's selectionRange, M-, back to the outline row, q restore. +#[test] +fn outline_panel_opens_visits_and_restores() { + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + std::fs::write( + &a_path, + b"l0\nl1\nl2\nl3 inner here\nl4\nl5\nl6\nl7\nl8\nl9\n", + ) + .expect("write a"); + + let mut state = open_against_fake(&a_path); state .lua_host @@ -6240,37 +6244,11 @@ fn outline_panel_opens_visits_and_restores() { /// q restores. #[test] fn hover_doc_panel_shows_full_contents_via_binding() { - use pmacs::editor::EditorState; - let dir = tempfile::tempdir().expect("tempdir"); let a_path = dir.path().join("h.rs"); std::fs::write(&a_path, b"fn main() {}\n").expect("write h"); - let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); - let fake = fake_lsp_path(); - state - .lua_host - .lua() - .load(format!("pmacs.lsp.config.rust = {{ command = '{fake}' }}")) - .exec() - .expect("override rust config"); - state - .lua_host - .lua() - .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) - .exec() - .expect("open h.rs"); - 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" - ); + let mut state = open_against_fake(&a_path); // The real chord: C-c, then Shift+h (terminals deliver uppercase // Char('H') with the SHIFT modifier set).