| Commit message (Collapse) | Author | Age |
| ... | |
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| |
|
|
| |
search/range look steps, goldens updated
|
| |
|
|
|
|
|
|
|
|
| |
config.tag_normal became a space when the mode moved into the layout box,
and 66 goldens still spelled the old bullet — the whole suite was red on
one character. Regenerated serially, and every file's diff is that
character and nothing else: replacing the bullets in the OLD goldens with
spaces is byte-identical to the new ones, so nothing else moved under
cover of the churn. The one bullet left in tutor.golden is the tutor's own
sentence about the badge, which the app still draws.
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
file:LINE:COL-ENDLINE:ENDCOL, with the two short forms people actually type
reading naturally: file:412:9-21 on one line, file:412-418 whole ones. Ends are
inclusive. A path feature, not a search feature — a ranged path typed in a tag
or middle-clicked out of a shell's output selects just the same; search is only
its first consumer.
The dash is the fussy part. `-` was already a file char, so a ranged word
survives click expansion whole, but a range needs a number on BOTH sides or
my-file:10, build-2 and 2026-07-30 would stop being paths. Table-driven test in
look.zig for exactly that.
Selecting goes through the cellRange/setPaneRange pair the multi-cursor work
left, and hxOff clamps both ends, so a stale range selects what still exists
rather than crashing or reaching past EOF — pinned with an 8:6-400:9 range in a
nine-line file.
Producers: / search, Grep, and five LSP sites through a new spanRow — goto,
references, rename tokens and both symbol lists were throwing away real
protocol ranges at path:line:col. Left alone deliberately: Find rows are bare
paths with nothing to span, a jump is a spot not a span, and the diagnostic and
format paths only ever have a point, where half a range would be worse than
none.
One knock-on worth knowing: n now leaves an EXPLICIT selection, so a topbar
execute chords it. grep.snap's no-match step was silently becoming
`Grep TARGET`; it runs from the leader path now, which never chords, and the
dedicated chord steps stayed where they were.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It never did — the effect arm was `.watch => {}` with a comment saying a shell
that never delivers the event simply never reloads. That was written as a
deliberate scope cut and it is the whole bug: the user runs pardes-gui.
The tty watcher was fine, and I proved all four suspicions false against a real
binary in a real pty with the write done by a stranger process and NO input
delivered afterwards: the loop does wake (postEvent signals an empty queue),
zig fmt's rename-over does fire MOVED_TO and is caught, three panes across two
directories all reload and closing one leaves its neighbour still following,
and the self-write hash guard does not swallow a real change. The live session
even had its inotify mark on src with the right mask and re-read config.zig
when I touched that directory.
Duplicated rather than shared with tty.zig, the way the two shells already each
own LspJob, forkShell and their pty readers. The headless PARDES_TEST_GRID path
deliberately passes -1: its contract is one frame per scripted input, and a
reload on its own clock would put an unasked-for frame in the stream.
filewatch.snap proved less than it looked. Its real blind spots were the
rename-over shape — the old script only truncated, so it saw CLOSE_WRITE and
never MOVED_TO — multiple directories, and the one that mattered: the suite
only ever runs the TTY binary, so it structurally cannot see a gui-only
regression. It now uses a new `run` directive whose writer is a child of the
RUNNER, so nothing it does reaches an app pty.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
228 in the ring: 3 pardes ships plus all 214 helix runtime themes and all 11
zed variants across its three families. No exclusions.
inherits is what this cost. 76 themes need it, so the reader is two passes now,
and the merge is WHOLE-KEY rather than field-wise because helix merges the
theme body at depth 1 — the three *_transparent themes clear their parent's
background with an empty table, and a field-wise merge would leave it painted.
I had that wrong until I read helix-view/src/theme.rs.
At 214 files the generator meets themes that are SPARSE rather than broken, and
the old fatal-on-anything-missing would have rejected them. Every fallback is
helix's own rule: no background at all means wear the terminal's, so bg and fg
go null (13 themes, correctly); the twelve RGB fields that cannot be null fall
back to ghostty's default palette entries 7 and 0, which is exactly what a
pardes terminal already paints an unstyled ANSI index as. fatal is kept for
what genuinely cannot be read.
Three real bugs surfaced only at scale: #ccc shorthand doubles the nibble
(helix's rule, 58 colours), a quoted inline-table key silently dropped a
selection colour, and four themes name a cursor background EQUAL to the page
because they are about to reverse that cell — taking it painted an invisible
move box. 0 degenerate themes out of 225 now: no fg==bg, no syntax colour on
its own background, no invisible box.
Collisions get an unconditional rule rather than a clever one: a zed variant is
always <name>_zed, because both projects ship gruvbox, ayu and one, and a
conditional tag would move a name when the other source changes. A comptime
assert holds it.
Cost: no-op build 0.257 -> 0.276s, a pardes.zig change 46.8 -> 47.5s, binary
+0.98%. The generator does 217 sources in 54ms.
NextColor stays and is no longer a way to REACH a theme — but the browse got
better, not worse: the generated half sorts by name, so its neighbours are that
theme's own family. theme.golden's fourth click moved from ayu_dark to acid,
which sorting 225 names does; themesel gained a tail capture proving row 228
renders.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
zig build perf drives the core directly — event, effects, one frame, no pty —
over four generated fixtures: 1k lines, 50k, 300k, and 400 lines of 8000
columns, because a file that is long and a file that is wide fail differently.
Every sample seeks somewhere else in the file first, since measuring at line 3
of a 300k-line file hides exactly the bug.
perf record said half the run was scanning for newlines from byte 0. So File
carries a line index, built on demand and invalidated in exactly ONE place —
setContent, the funnel every content swap already goes through. That killed the
scrollbar's per-frame line count (12.6% of the whole run by itself), scrollBy,
ensureCursorVisible, lastNavRow, the syntax window bounds and two O(scroll)
walks. normalKey computed max_line as a const at the top: two full passes over
the buffer on every keystroke of every kind, for three g/G branches. It is lazy
now. The modal primitives each walked the text twice for the same line.
And the visible window was re-parsed on every scrolled row — a third of a
megabyte per keypress on the wide fixture. The highlighted range is remembered,
a scroll inside it is free, and only a re-parse that FOLLOWS a scroll takes
slack: doing it unconditionally made typing 2.1x slower, since every character
paid for a band it could never amortise.
One j on a 19 MB file: 37.8ms -> 266us. Render: 4.1ms -> 77us. Open costs 1.25x
more for the one extra pass, which buys 54x on every frame after, and 8 bytes
per line of memory.
Left standing, measured and named: edit-char is 14ms on 19MB because content is
immutable and every keystroke copies the buffer. A third of that is the index
rebuild, which could be a shift if setContent knew the edit offset; the rest
wants a rope. bodyText's double copy and Surface.print's per-cell decode never
rose above 2% of the profile afterwards, so they were left alone.
No golden moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and
sels[] holds helix's OTHER ranges. That split is why nothing moved at one
cursor: with nsel == 0 not one line of the existing motion, operator, render or
mouse code takes a different branch, which is what protects 800 differential
cases and 67 goldens.
paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's
Selection::new (min width 1, sorted, overlaps merged, primary follows its range
through a merge). An ordinary key runs the single-selection handler once per
range, visited last-first so an edit never disturbs a range still waiting, and
each finished pass is remembered as a distance from the END of the text, which
an earlier edit cannot move — helix's change mapping without a change map.
pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from
the primary and stops the replay, which also closes the use-after-free window
if it frees the pane.
s and S reuse the / prompt wholesale rather than growing a second one: the
pattern is typed into the tag tail, and every keystroke re-runs the match from
the selection the prompt opened on, so the preview is live and Esc is just the
empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size
struct with no allocator — with 64 ops and 8 char classes per pattern, no
case-insensitive flag (helix's smart case is done by folding a scratch copy),
no captures, no multi-line anchors. The last two are the two waivers.
Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides
comment-vs-uncomment ONCE for the whole selection; replaying it would take that
decision n times. Comment tokens are a table in config.zig keyed on the same
extension syntax.zig picks grammars by.
Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left
the cursor one cell before where the append began. helix's restore_cursor can
never walk past the origin; ours backed up unconditionally. hxdiff was green
before AND after — the old one-selection contract could not see it.
hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the
harness contract now reports every range and its primary, omitted when there is
one, so 359 of the 360 old goldens are byte-identical. The one that moved is
o-count: helix's 2o really does leave two cursors and could not say so before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An external update is pushed onto the undo stack exactly like an edit the user
typed, so unsaved work is one `u` away and pardes never has to merge anything.
That is the design, not an implementation detail: the whole feature is
pushUndo() then setContent().
One inotify instance in the tty shell, blocking in readVec through std.Io on a
concurrent task started beside the pty readers — after loop.start(), so the
forkpty ordering is untouched. It watches the containing DIRECTORY, because an
editor rewrites by rename-over and a watch on the file would follow the dead
inode, and it listens for CLOSE_WRITE rather than MODIFY, which is one event per
finished writer and most of the debounce for free.
Our own Save does not reach the undo stack: each watch keeps a hash of the
bytes last seen on disk and save_file restamps it. A hash rather than mtime and
size because the reload has to read the file anyway.
The core stays sans-IO — one watch effect out, one file_changed event in, and a
shell that cannot watch simply never sends the event, which is what the gui and
the web platform do. Linux only; fanotify is what the build system uses and is
rejected in a comment: it exists for thousands of directories across mounts,
and sixteen panes of inotify is a third of the code with no kernel floor.
New golden filewatch: edit without saving, overwrite from a shell in another
column, watch it reload, undo, get the unsaved edit back. None moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
queryTerminal(2ms) blocks on a futex until DA1 comes back, and the number has
to beat one terminal round trip: local answers in microseconds, ssh localhost
under a millisecond, any real link never. Measured against sshd with the
replies delayed to model the wire, 2ms already loses at 5ms RTT.
Losing it is worse than never probing, because vaxis splits detect from enable
and only detect respects the deadline. The flag flips the moment the futex
times out, so the two replies gated on it — explicit width and scaled text,
both spelled as a cursor-position report — stop being read as probe replies and
arrive at the app as shift-F3 and alt-F3 keypresses, while the ungated ones
keep mutating caps from the reader thread long after enable already declined to
switch those modes on. Over ssh the terminal sat in its default modes while
caps claimed otherwise: kitty keyboard was never actually pushed, ever.
So send the probes and resolve them on the loop. DA1 is last and terminals
answer in order, so when the reader flips the flag every earlier reply is
applied — no window to miss at any latency. Verified over real ssh at 5 through
500ms RTT: 7/7 caps and kitty keyboard actually enabled at every one, where
before it was 5/7 and never. Startup is 2ms faster, no golden moves (nothing
answers in the harness, and with no caps enable writes no bytes).
Honest scope: I could not reproduce the reported stale characters, only the
handshake bug behind them. The width half of the theory is inert — vaxis's
Cell.width defaults to 1 and pardes writes one codepoint per cell with an
explicit spacer, so gwidth, the only consumer of caps.unicode, is never called.
That is written down so nobody re-derives it. If the dirty screen survives,
the next suspect is vaxis's own carry-over for an escape sequence split across
a read boundary (Loop.zig:174-190, wrong length and an off-by-one): four wheel
events sent whole scroll four notches, the same four split at a `;` with a
60ms gap scroll zero. Network framing is exactly what makes those gaps. It is
an input bug in a vendored dep and wants its own change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pad faithfully turns a two-finger scroll's sideways drift into wheel_left
and wheel_right, so a plain scroll slid the view sideways underneath you. Every
vertical tick now re-arms a guard and every horizontal tick spends one instead
of scrolling, so horizontal has to EARN its way back by landing three ticks in
a row with no vertical among them.
Clock-free on purpose: the core is a state machine with no timestamp on a mouse
event, and faking one by counting renders would be worse than the counter. The
guard is only ever armed BY vertical scrolling, so a horizontal swipe from a
still view still moves on its first tick — only horizontal that interrupts
vertical has anything to prove.
A tilt wheel gets the same treatment, where recent-vertical is a much weaker
signal of accident. Deliberate: the only honest fix is a per-device flag out of
the shell, and that layer costs more than the three clicks it would save.
The rule is one pure function next to the number it reads, with an inline test
written to hold for any tuning of that number. unit-test grew a fourth binary
over the core module hxdiff already links. New golden wheeldrift; none moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
placeDoc picked its source by SLOT INDEX — it scanned panes from 0 and took the
first doc it found — so which column your file landed in depended on internal
id allocation, not on where you had been. It now walks the jump stack backwards
to the newest live doc pane.
No new state: jumps already IS the record of where the keyboard has been, and
Pane.serial is already the "this slot was reused" test. The slot-order scan
stays as the fallback, deliberately: a restored dump has one jump entry, and if
that pane is a shell the stack knows no doc — without the fallback the next
file would claim a second column with docs already open.
lookpanes moved: a file now lands under the doc that was actually last read
rather than under whichever doc happened to hold the lowest pane id. New
golden doccol pins the case the line exists for, and it fails at exactly one
capture with the walk disabled.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Not a second builtin: Help already rendered "every builtin with a leader path
and the key that runs it", and the ask is "every builtin and every shortcut" —
the same listing with three more rows and one more column. A new builtin would
have been a second renderer over a superset of the same rows, and the two would
drift the first time a column moved. The prefix filter is now the degenerate
case of the index rather than a separate mode: SPC h ? reads the same array
with prefix "h", and the path-less builtins fall out on their own because an
empty path starts with nothing.
The shortcut column is folded, never typed: chordName spells a chord from Key's
OWN declarations, so a new special key names itself and an unnamed
non-printable codepoint is a compile error instead of garbage; shortcuts()
walks the cmd columns of window_keys and jump_keys, look_cmd/exec_cmd with
their keys and buttons, and topbar_str. Retarget a binding in config.zig and
this re-renders.
So Look reads `enter, right-click`, Back reads `C-o`, Kill reads `topbar`, and
Theme reads nothing at all — which is the information.
Not a picker, deliberately: n/N stepping a list holding Kill, Del and Delcol
would end the session. The names are live text, so middle-click the one you
want.
Alt-n, Alt-c, the tty toggle and the cut/paste chords cannot appear — they are
inline handlers that reach no builtin, so there is no row to put them on. Make
one a struct with a cmd column beside its binding and it appears for free.
Three goldens moved, all of them the +Help buffer's contents.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Each theme is a .zig file of pure data and nothing enumerates them by hand —
fold() walks the container's declarations, so a theme is a file and that is the
whole registration. Field by field rather than a wholesale coercion, which
makes a missing field a compile error that names it.
tools/gen_themes.zig reads helix .toml and zed .json out of vendor/themes and
emits one .zig each; build.zig reads that directory, so adding a theme is
dropping a file in. Output goes to the build cache rather than the tree, so zig
owns the freshness check and a deleted source cannot leave a stale theme
behind. Vendored, not read from the genizah: the build stays offline. A source
it cannot map is a hard error naming the file and the key, never a silently
black-on-black theme.
ThemeSel is the clever half. Traits gained an `executes` column, so n/N over
that buffer hands the whole line to Exec instead of the leading word to Look —
both arms the ordinary builtin, so a stepped row does exactly what the matching
mouse button on it would. Stepping the list previews each theme live. The trait
is a pane property, not an output-pane branch, so any pane whose lines are
commands can opt in.
Goldens: leader gains SPC t t; theme's fourth NextColor no longer wraps to
helix because the ring is fifteen long. New: themesel.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is
now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that
array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's
directory order, Back/Forward, Last and Jumplist all walk the one list.
A pane id is reused, so a location that only remembered an id would retarget
after a respawn: panes now carry a monotonic serial and an entry whose slot
holds a different serial is dead. trackJump compacts those out and fixes the
pointer in the same pass.
The push rule lives in ONE place and says: a location is worth remembering
when you cannot see it any more — a different pane, or more than a bodyful of
rows away in the same one. So hjkl never grows the list and 100G, a search hit
and a goto-definition do.
Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them.
Ctrl-i IS Tab on a legacy host, where the binding simply never fires and
Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere.
Two goldens moved, both the SPC ? Help listing gaining four rows.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Their only special feature is now the keys they are assigned. `Look main.zig`
typed in a tag is the same look a right click is; config.look_cmd/exec_cmd
point the two buttons and Enter/Tab at them, so retargeting Enter to Grep is
one line. actOnSelection — a hand-written cascade with the builtin dispatch
nested inside it — is gone, and the `button` parameter it threaded through five
KEYBOARD call sites went with it.
New syntax, spelled once in config.zig: @`ls -la` names a command to run
rather than a file to open. Word expansion takes the quoted run whole, the way
acme does for its own </|/> words, so a click inside one does not hand Look the
fragment `ls`.
And it nests: @`Look .` unwraps, re-enters the dispatcher, and looks at the
directory. Guarded at depth 8, which is unreachable today (every re-entry
strips a word or a delimiter pair, so the string strictly shrinks) and exists
for a future syntax that does not shrink.
leader_path is now optional: a builtin may have no SPC path when its shortcut
is a key and a mouse button. New golden cmdword; the other 59 unmoved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An output buffer was a file pane with a flag, and everything special about it
was re-derived from outside by asking the wrong question: the buffer NAME
("+Search", "+Help"), a parallel search_kind field, and f.output predicates
sprinkled through kind-agnostic code.
Now it carries its Origin — the Builtin, or the lsp.Kind for the five gotos
helix binds as motions rather than words — plus that command's argument, and
one Traits table answers every question the scattered code used to ask: what
it is called, whether n/N steps it, whether it is a doc for layout, whether
Esc dismisses it, whether it saves. The name is derived FROM the command
instead of being the thing that identifies it.
Adding a kind of output pane is adding a row. No golden moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Retargeting a key, a mouse chord or a spelling is now an edit to src/config.zig
and nothing else. Three parts in reading order: pardes's own bindings (where a
reader lands), the Look/Exec syntax, then the helix keymap under a banner
saying hxdiff/hxparity are differential suites against real helix, so a key
moved there is a divergence and not a tweak.
The bindings are data a comptime loop can walk, because the builtin index is
going to walk them.
Duplication this cut: is/isC/isA became one hit(key, chords) over ~132 call
sites, and a binding being a LIST collapses the letter-or-arrow chains;
swap_enter_tab is gone, replaced by look_key/exec_key that can point anywhere;
the four focus builtins' h/j/k/l lived hardcoded in two places and is now one
table.
No golden moved.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The seam's `query` now calls ZLS's analyser directly, on the worker thread,
in this process. There is no zls binary, no subprocess, no JSON-RPC, no
`initialize` handshake and no `Server` — `gd` is a function call whose answer
comes back as rows. ZLS's build.zig already publishes its guts as an
importable module (`b.modules.put("zls", ...)`), so this is a path dependency
on the local 0.16.x checkout plus one new file, `src/lsp_zls.zig`.
Construction is ZLS's own (tests/analysis_check.zig does exactly this):
InternPool.init -> DiagnosticsCollection -> DocumentStore struct literal ->
Analyser.init. `zig_exe_path` is null on purpose — shelling out to the
compiler is the thing this backend exists to avoid — and `zig_lib_dir` is
baked in from `b.graph.zig_lib_directory`, so `gd` on `std.mem.count` opens
the same mem.zig the compiler used, with ZIG_LIB_DIR overriding at runtime.
Offsets are `.@"utf-8"`, not ZLS's utf-16 default: `+Search` rows are byte
columns and we are not on a wire.
Seventeen probes, seventeen answering, no false claims, 5.9 MiB peak RSS.
The features that were already Server-free are calls (hover, document
symbols, code actions); the ones welded to `*Server` are reimplemented thin
on top of public primitives — goto is gotoHandler minus the protocol,
diagnostics is the in-process `std.zig.AstGen` branch of
getAstCheckDiagnostics, references is symbolReferences' algorithm from the
outside (offer every same-named identifier token back to the analyser and
keep the ones that resolve to the same decl, so a shadowed local is not a
false hit).
What it does not do, deliberately:
- Nothing is cached between queries. Each `query` builds a DocumentStore,
resolves imports and throws it away, because the arena dies on return and
`req.source` is a snapshot of a buffer the user is still typing into. So
cold IS warm — there is no index to warm up. It is also fast enough not to
need one: 124us for a local goto, 2.8ms into the stdlib, 8ms for
references over a 5000-line file. A cross-query cache is a real design
(a global, a mutex, an invalidation story), not a line of code, and it is
the obvious next step rather than something smuggled in here.
- References, rename and select-refs are THIS FILE only. Workspace-wide
means loading every project file into the store and running the analyser
over each; DocumentStore's own workspace iteration has the same limit
(it can only see handles already loaded). Workspace symbols and workspace
diagnostics DO walk the tree, because neither needs the analyser — a
parse and a tree walk each.
- Rename previews, format reports, code actions list. The seam hands back
rows, not edits, so there is no channel through which a backend could
rewrite the buffer. These answer the question the keypress asks and change
nothing.
- Without a zig binary, `@import("builtin")`, `@import("<pkg>")` and
`@cImport` resolve to nothing — silently, which is ZLS's behaviour, not a
bug introduced here. Relative imports and `std` work.
- Non-.zig files answer nothing. The core does not gate the keymap by file
type, so the gate is here: `gd` in a README must find nothing rather than
parse prose as Zig and confidently resolve a word out of it.
A whole-file report that ran and found nothing says so ("no diagnostics",
"already formatted", "no code actions") rather than returning zero rows,
because in this seam zero rows already means "no backend" — `lspResponse`
opens nothing for an empty answer, so silence cannot also mean "checked,
clean". Location queries keep the opposite rule: unresolvable is no rows.
test/snapshots/lsp.snap covers the round trip end to end — gd jumping on a
single result, SPC k opening +Hover, SPC s opening the +Search list that n
steps, and gd on a keyword answering nothing without opening anything. The
whole backend was also fuzzed at 20k queries over real, truncated and
byte-smashed sources across every kind; that found two crashes (a decl's
name token indexes its own file, not the requesting one, and is not
necessarily an identifier at all on a half-typed line) which are fixed.
emscripten does not get the backend: the web shell has no threads and
no-ops the lsp effect, so it keeps the empty one the base tree shipped.
DEPENDENCY: ZLS is FETCHED by the build system (build.zig.zon .url + .hash,
pinned to commit 3e0d0820 on the 0.16.x branch) rather than a path
dependency on the local genizah checkout, so it lands in zig-pkg/ like
every other dependency and the build is reproducible from the .zon alone.
Also passes -Dversion-string: ZLS's build.zig names itself by shelling out
to `git describe`, and a fetched package is an extracted tarball with no
.git, so every build printed a 'Failed to run git describe' warning. We
pin the commit, so we already know the answer.
|
| |
|
|
| |
Plus docs/lsp-evaluation.md: the three backends measured head to head.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The base every language backend plugs into. Three parts:
ASYNC. The core had no request/response shape - every effect was
fire-and-forget or instantaneous. A language query is the first thing
that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp.
tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread
+ the mutex queue it already had for ptys; web no-ops it. The worker
never touches the core (path/source/arg are snapshotted into an LspJob),
one query in flight identified by a monotonic id so a second press makes
the first answer stale, and no rows is a legal answer.
KEYMAP. Helix's, verified against its default.rs rather than recalled.
gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so
pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc
(closing a pane is a window op, and c is helix's own close), Dump/Restore
s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map.
RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what
look.zig resolves and n/N step, so one row from a goto jumps and several
open a buffer - helix's multi-result picker needed no picker code.
Backends supply exactly one function (lsp.query) plus a supports set and
a name; the base has none on purpose. zig build lspbench scores them on
the same corpus: feature matrix (trusting results, not the supports
flag - a claimed-but-empty kind is reported as a false claim), cold and
warm latency, peak RSS.
Two snapshot scripts moved. leader.snap encoded the old key paths.
chordcut.snap's last two steps clicked column 5, which lands on a FILE
pane, so 'key c-b' toggled nothing and the typed text was being read as
normal-mode keys - the golden recorded no TTY pane and no cat -v output
anywhere. Pointing them at an actual shell makes both steps assert what
their comments claim, and the tty paste chord is now covered for the
first time.
|
| | |
|
| | |
|
| | |
|