diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-29 10:20:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | cc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa (patch) | |
| tree | 23dadf74493c0172ea7fced1dff44d2a4178c752 /src/pardes.zig | |
| parent | 4e642c1d6688baf3b98f269818b66cc7cc194c5f (diff) | |
| download | pardes-cc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa.tar.gz pardes-cc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa.zip | |
lsp: writer seam, ZLS introspection builtins, ctrl-click goto, SPC l group
THE SEAM TAKES A WRITER. `lsp.query`'s `out` is a `*std.Io.Writer`, not a
`*std.ArrayList(u8)`. The shell owns the buffer behind it (an
`Io.Writer.Allocating`), so a backend never allocates the result, never frees
it, and cannot get the allocator wrong — the invalid-free class of bug has
nowhere left to live. It also deleted a parameter from five functions: they
only ever took a `gpa` to allocate rows, and 0.16's unused-parameter error
found every one. `lsp.row()` lost its allocator argument too.
Since a Writer cannot rewind or be counted, `query` renders into a scratch
Allocating first: the log wants an exact row count, and `explain` throws the
rows away and prints narration in their place.
INTROSPECTION. `SPC l i` (Lspinfo) and `SPC l w` (Lspwhy), in the `l` group
that now holds every language command (see below).
They exist because of the seam's own contract: a backend never fails loudly,
which is right for an editor, but it makes a broken backend and a correct one
that found nothing look identical from the outside. Every query now leaves a
record — kind, file, offset, duration, row count, and THE ERROR `run` returned,
which `catch {}` swallowed and which was visible nowhere. Lspinfo prints those,
plus which ZLS is compiled in, which zig lib dir and whether it actually opens
(the usual cause of "gd does nothing in std"), and what the backend answers
versus refuses. It answers from ANY pane, including one with no file, because
it is about the backend — which matters precisely when the pane you are sitting
in is the problem; both shells now send status for a file-less pane.
Lspwhy narrates the REAL resolution path. The trace is threaded through `goto`
itself, so what it prints is the position context the analyser returned and the
branch that actually stopped. A debug view that re-derives the logic beside it
is one that can disagree with it.
CTRL-CLICK IS gd. Mouse gained a `ctrl` field, set by both shells (SDL asked
directly via GetModState rather than read off key-event bookkeeping, which a
click with no prior keypress would miss). The flag rides the drag rather than
firing on the press: a click does not place the modal cursor until RELEASE, so
a query asked at press time would answer about wherever the cursor previously
sat. A ctrl-DRAG still selects.
The snapshot DSL gained a `ctrl-` button prefix (SGR bit 4, what a terminal
sends and what vaxis decodes). test/snapshots/lspdebug.snap covers all three,
including a PLAIN click in the same spot that must NOT jump — without it the
test would pass on a bug that made every click a goto. Durations cannot live in
a golden, so PARDES_LSP_NOTIME (set by the harness, like PARDES_DUMP) omits
them.
58 snapshot scripts, hxdiff 360, hxparity 440, unit 46, gui build: all green.
lspbench: 17/17, 0 false claims.
THE WHOLE LANGUAGE GROUP LIVES UNDER SPC l.
pardes keeps its own leader letters back. `SPC d` is Del again, `SPC k` is
Kill, `SPC s d`/`SPC s r` are Dump/Restore and `SPC h t` is Tutor — exactly
where they were before the language work touched them.
The previous pass put the LSP commands on helix's bare `<space>` letters and
moved pardes's builtins out of the way (Kill k->q, Del d->wc, Dump/Restore
s?->f?, Tutor ht->T). That was the wrong trade. Those five are the most-pressed
keys in the editor and predate the language work; an LSP command is something
you reach for deliberately and can afford one keystroke more.
So every LSP command keeps HELIX'S OWN LETTER and gains the `l` prefix:
`<space>k` -> `SPC l k` (hover), `<space>d` -> `SPC l d` (diagnostics),
r/a/h/s/S/D likewise. Nothing to re-learn but the prefix, and `Lspinfo`/
`Lspwhy` were already there.
THE GOTOS ARE UNTOUCHED. `gd` `gD` `gy` `gi` `gr`, `]d`/`[d`, `]D`/`[D`, `=`
and ctrl-click all stay exactly as helix has them — they never collided with
anything, so there was never a reason to move them, and they are the ones you
actually press mid-edit.
leader.snap is restored to the pre-LSP script (its `key q` unmapped-key step
works again now that Kill is back on `k`) plus one new step for `SPC l ?`. Its
`SPC ?` root listing had to stop waiting on Restore: the full list grew to 33
rows and row 21 falls off the pane, so it watches an early row instead.
DEPENDENCY IMPORTS NOW RESOLVE. `gd` on `@import("vaxis")` opens vaxis's root
file; before, it silently did nothing while `std` worked perfectly.
The asymmetry was not a wiring mistake. ZLS's uriFromImportStr answers exactly
three ways: a relative `.zig`/`.zon` path from disk, `std` from `zig_lib_dir`
(one directory, which we supply), and EVERY OTHER NAME only by running
`zig build --build-runner` to discover the module graph. That last branch needs
`zig_exe_path`, which this backend sets to null on purpose — so every
dependency import returned `.none`. Confirmed twice over: in ZLS's source, and
by `SPC l w` on the import string, which printed the STOP line naming exactly
that branch. (The introspection builtin diagnosing its own backend on its first
real outing is a decent argument for having built it.)
We never needed a compiler for this: build.zig IS the module graph. It folds
`root_mod.import_table` into a name -> root-source-file table at configure
time and passes it as a build option; the backend consults it precisely where
ZLS gave up. Correct by construction — a dependency added or renamed in
build.zig cannot forget to update it — and it costs no subprocess, no build
step and no runtime work. `SPC l i` now lists the table, since "is this name
even importable" is the first question when a jump does nothing.
Two limits, both stated in the code: a module whose root is a GENERATED file
is skipped (it has no path until make() runs), and a file inside a dependency
importing that dependency's OWN internal module name is still a miss — that
would mean running its build.zig.
TRAP: the table is folded out of root_mod.import_table, so `addOptions` had to
move BELOW every `addImport` call. Attached where it was, the table is empty.
TOPBAR GAINS `Help`, WHICH IS WHY `SPC ?` LOOKED BROKEN.
A bare `pardes` boots straight into tty mode (main.zig: `args.len == 1`), where
every printable key belongs to the shell — so SPC never reaches the leader, and
`SPC ?`, the one thing that would tell you the leader exists, is exactly the
thing you cannot press. Ctrl-b first and it all works; nothing was broken. But
"the help is unreachable until you already know the escape hatch" is a bad
answer, and there was no mouse route either: Help was the one builtin missing
from the bar.
Row 0 is not a pane, so a middle-click there is dispatched before any pane's
mode is consulted — the word works in tty mode, which is the only reason it
earns the width. APPENDED, not inserted, so every existing topbar word keeps
its column and no golden's click coordinates move. test/snapshots/ttyhelp.snap
pins it from a bare boot: click Help, get the list, shell still TTY at its
prompt, then Ctrl-b + SPC ? for the keyboard route.
All 58 goldens carry row 0, so all 58 moved. Verified mechanically that the
only changes are the row-0 text and the row-0 style run (0-47 -> 0-52), plus:
dump/restore record the topbar inside their .zon, and tagnav's `$`+Enter now
executes `Help` rather than `Grep` because the bar's last word changed — still
exactly what that step's comment claims it tests.
THE DEPENDENCY FIX HAS A CEILING, NOW STATED. The module map is consulted from
OUR goto handler, not from inside ZLS, so the analyser still cannot type the
`vaxis` const: `gd` on `@import("vaxis")` opens the file, `gd` on `vaxis.init`
finds nothing. That is now spelled out at the top of lsp_zls.zig and on
moduleRoot rather than left implied, and `SPC l w` detects the case by name —
if the left side of a failed field access is a known dependency it says so,
instead of the generic "could not resolve". Lifting it means giving ZLS a real
BuildConfig, either by letting it run the build runner (a subprocess, and with
no cross-query cache that is once per keypress) or by synthesizing one into
BuildFile.impl. Both are real work and neither is smuggled in.
Also fixed while there: the field-access miss was only explained when ZLS
returned null, but it returns an EMPTY SLICE when it typed the left side and
found no such member. Both are "gd did nothing" from the outside; both are
explained now.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 124 |
1 files changed, 86 insertions, 38 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 682b0e3e..07120954 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -46,7 +46,7 @@ pub const swap_enter_tab = false; // each other: they are one pair — Find matches file NAMES, Grep their CONTENTS // — and every word's start column is what a topbar click dispatches on, so // appending is the edit that leaves the bar people already know where it was. -pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep"; +pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep Help"; const pane_builtins_str = "Del"; const file_pane_builtins_str = "Save Del"; const pane_tail = " " ++ pane_builtins_str; @@ -100,13 +100,12 @@ const Builtin = enum { Up, Right, Toggleterm, - // The language group. Every one of these is a helix `<space>` command and - // keeps helix's key; the pardes builtins that used to sit on k/d/s moved - // (see leader_path). They are builtins rather than bare keys for the same - // reason Save is one: the word is executable wherever it appears, so a - // middle-click on `Hover` in a tag does what SPC k does. The five GOTOS - // are not here — helix binds them under `g` as motions, and a motion has - // no business being a word you can click. + // The language group, reached as `SPC l <helix's letter>` — see + // leader_path for why the prefix exists. They are builtins rather than + // bare keys for the same reason Save is one: the word is executable + // wherever it appears, so a middle-click on `Hover` in a tag does what + // `SPC l k` does. The five GOTOS are not here — helix binds them under `g` + // as motions, and a motion has no business being a word you can click. Hover, Rename, CodeAction, @@ -115,38 +114,54 @@ const Builtin = enum { WsSymbols, Diagnostics, WsDiagnostics, + // Introspection. A language backend that answers nothing looks exactly + // like one that is broken — from the outside, `gd` doing nothing is both + // "there is no definition" and "the analyser threw and we swallowed it". + // These two are how you tell: Lspinfo says what the backend IS, Lspwhy + // says what it just DID and where it stopped. + Lspinfo, + Lspwhy, }; /// SPC leader: ONE key path per builtin, the whole remapping surface. An /// EnumArray, so a new enum field is a compile error until it has a path. /// Groups are just shared first letters (f files, h docs, c columns, t -/// toggles, s session); `?` is Help's path and is honored at ANY depth — it -/// lists what the prefix typed so far can still reach. +/// toggles, s session, l language); `?` is Help's path and is honored at ANY +/// depth — it lists what the prefix typed so far can still reach. const leader_path = std.EnumArray(Builtin, []const u8).init(.{ .Help = "?", - // HELIX OWNS THESE EIGHT LETTERS. The LSP commands keep the exact keys - // helix gives them, which is the whole point of pardes's modal layer being - // helix's — a muscle-memory map with three exceptions is not a map. What - // moved is ours: Kill k->q, Del d->wc (it closes a window, and helix's - // window group already spells close `c`), Dump/Restore s?->f? (writing and - // reading a session file is a FILE operation), Tutor ht->T (freeing `h`). - .Hover = "k", - .Rename = "r", - .CodeAction = "a", - .SelectRefs = "h", - .Symbols = "s", - .WsSymbols = "S", - .Diagnostics = "d", - .WsDiagnostics = "D", - .Kill = "q", - .Tutor = "T", + // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep + // the letters they always had — `SPC d` is Del, `SPC k` is Kill. + // + // Helix puts these on bare `<space>` letters, and an earlier pass followed + // it there, which cost `d`, `k`, `s`, `h` and the session group. That is + // the wrong trade: those five are pardes's most-pressed keys and predate + // the language work, whereas an LSP command is something you reach for + // deliberately and can afford one more keystroke. Each one still keeps + // HELIX'S OWN LETTER inside the group, so the mapping is `<space>X` -> + // `SPC l X` with nothing to re-learn but the prefix. + // + // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy` + // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so + // there was never a reason to move them. + .Hover = "lk", + .Rename = "lr", + .CodeAction = "la", + .SelectRefs = "lh", + .Symbols = "ls", + .WsSymbols = "lS", + .Diagnostics = "ld", + .WsDiagnostics = "lD", + .Lspinfo = "li", + .Lspwhy = "lw", + .Del = "d", + .Kill = "k", // the `f` file group (spacemacs): Save left vim's `w` to join Find here, // which frees `w` for the window group (SPC w h/j/k/l) to move into. .Save = "fs", .Find = "ff", .Grep = "fg", - .Dump = "fd", - .Restore = "fr", + .Tutor = "ht", .Newcol = "cn", .Delcol = "cd", .Debug = "td", @@ -158,16 +173,16 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{ .Petscii = "tp", .Palette = "tl", .Ascii = "ta", + .Dump = "sd", + .Restore = "sr", // the `w` window group `Save` vacated: the four directional focus moves // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus - // `t` for the file<->terminal hop and `c` for close (helix's own spelling - // in its window mode, and where Del landed when `d` went to diagnostics). + // `t` for the file<->terminal hop. .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", .Toggleterm = "wt", - .Del = "wc", }); /// The leader tree, flattened: one row per builtin SORTED BY PATH, so every @@ -218,6 +233,15 @@ const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { // forget, and a bar you read every frame should not spend width on them now // that `SPC t c` / `SPC t r` press them. NextColor stays — it is the one you // cycle repeatedly, so a click beats a three-key path. +// +// Help is LAST and is the one word that has to be here. A bare `pardes` boots +// straight into tty mode (main.zig: `args.len == 1`), where every printable +// key belongs to the shell — so SPC never reaches the leader and `SPC ?`, the +// thing that would tell you the leader exists, is exactly what you cannot +// press. Row 0 is not a pane, so a middle-click on it is dispatched before any +// pane's mode is consulted: this word works in tty mode, which is the only +// reason it earns the width. Appended rather than inserted so every existing +// word keeps its column and no golden's click coordinates move. comptime { @setEvalBranchQuota(8000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name var it = std.mem.tokenizeScalar(u8, topbar_str, ' '); @@ -517,6 +541,11 @@ pub const Mouse = struct { kind: Kind, col: u16, row: u16, + /// Ctrl held during the click. Only the left press reads it (ctrl-click = + /// goto-definition, the one chord every editor with an LSP has); every + /// other button ignores it, because acme's button semantics are already + /// the vocabulary here and modifiers are not part of it. + ctrl: bool = false, }; pub const Key = struct { @@ -821,7 +850,7 @@ pub const Pane = struct { reply: [256]u8 = undefined, reply_len: u16 = 0, - fn cwdSlice(p: *const Pane) []const u8 { + pub fn cwdSlice(p: *const Pane) []const u8 { return p.cwd[0..p.cwd_len]; } @@ -930,7 +959,7 @@ const Drag = union(enum) { tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert - select: struct { id: usize, button: Mouse.Button, chorded: bool = false }, + select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false }, }; const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; @@ -2899,13 +2928,20 @@ pub const Pardes = struct { fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; - const f = pane.file orelse return; - if (f.output) return; + // `status` is about the BACKEND, not about a document, so it answers + // from ANY pane — a terminal, a +Search, anywhere. That matters + // precisely when the pane you are sitting in is the thing going wrong. + // Every other kind needs a real file: a terminal's rows are a + // program's output and an output buffer is our own text. + if (kind != .status) { + const f = pane.file orelse return; + if (f.output) return; + } if (arg.len > 128) return; // the effect's arg is a Buf(128) - const off = modal.hxOff(f.content, .{ + const off = if (pane.file) |f| modal.hxOff(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), - }); + }) else 0; p.lsp_seq +%= 1; p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id }; p.emit(.{ .lsp = .{ @@ -4383,7 +4419,11 @@ pub const Pardes = struct { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[0] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; - p.drag = .{ .select = .{ .id = id, .button = .left } }; + // Ctrl rides on the drag rather than firing here: the + // click does not place the modal cursor until RELEASE + // (dragRelease), and a goto asked at press time would + // answer about wherever the cursor happened to be. + p.drag = .{ .select = .{ .id = id, .button = .left, .ctrl = m.ctrl } }; } }, .drag => p.dragUpdate(mcol, mrow), @@ -4564,6 +4604,12 @@ pub const Pardes = struct { // dismisses it rather than dragging the anchored span // to the click (explicit v/x keeps vim's click-extend) if (!pane.vsel.explicit) pane.vsel.active = false; + // Ctrl-click IS `gd`, asked now that the cursor has + // landed — the mouse spelling of the keyboard motion, + // through the identical request. A ctrl-DRAG still + // selects and still asks, about where it started, + // which is the same thing `gd` would answer. + if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { // acme execute (middle) / look (right): a no-drag click @@ -5269,6 +5315,8 @@ pub const Pardes = struct { .Symbols => p.lspRequest(id, .document_symbols, ""), .Diagnostics => p.lspRequest(id, .diagnostics, ""), .WsDiagnostics => p.lspRequest(id, .workspace_diagnostics, ""), + .Lspinfo => p.lspRequest(id, .status, ""), + .Lspwhy => p.lspRequest(id, .explain, ""), // The two that need a word from the user, handled exactly the way // Find and Grep are: an argument means run it now (a selection // chorded onto the name), no argument arms the tag input and Enter |
