summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 04:20:20 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit628aa40f13e9bbd313b51ab625f193110aad8dd0 (patch)
treee60902495e308505959361d8719c382401b88295 /docs
parent0a15af8d98771180e32402e68ea844f9f377cefb (diff)
downloadpardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.tar.gz
pardes-628aa40f13e9bbd313b51ab625f193110aad8dd0.zip
Tab after a dot in insert mode lists what could go there
Diffstat (limited to 'docs')
-rw-r--r--docs/lsp.md89
1 files changed, 88 insertions, 1 deletions
diff --git a/docs/lsp.md b/docs/lsp.md
index c89c42f7..0c6a2f97 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -71,6 +71,76 @@ locations (`hover`, `code_action`, `format`, `rename`) open `+Hover`/`+Lsp`
instead and do not arm the stepper — `n` over a documentation blurb would step
to nowhere.
+`completion` is the kind this shape changes the most. Every other editor answers
+a dot with a popup of NAMES to insert; a seam that returns locations cannot
+insert anything, so this one answers with the candidates' **declarations** —
+one `path:LINE:COL-ENDCOL` row each, in the same `+Search` buffer, steppable
+with `n`. That is a different and arguably better answer to "what goes here":
+you read the definitions rather than a list of words. It is the one location
+kind that does NOT jump on a single row, because with one candidate you still
+want to see the list rather than be teleported into it.
+
+A results buffer is REFILLED rather than reopened when the same kind is asked
+again — the rule `runSearch` always had, and which the language path was
+missing. It survived being missing while every query was a deliberate press
+(`gr` twice left two identical lists and you closed one); Tab after a dot is an
+ordinary typing keystroke, and measured, twenty of them stacked **fifteen**
+byte-identical `+Search` panes, crushed the file to one visible line, and then
+ran `freeSlot` out so the key was silently eaten for the rest of the session.
+Unlike a search the ARGUMENT is not part of the identity: a language query is
+asked about a different symbol every time with the same (usually empty) arg, so
+the kind is the unit.
+
+Making it work needed one trick. A completion is asked for exactly when the
+line is half-typed, and a half-typed line does not parse: `switch (e) { . }`
+loses the whole switch to the parser's error recovery, taking with it every
+ancestor an expected-type resolution needs. ZLS answers this with a private
+token scanner welded to its `*Server`. `lsp_zls.completionSource` instead makes
+the tree PARSE — it splices a placeholder in after the dot, in the six
+spellings a half-typed line can need (an identifier, and the same again closing
+a prong, a statement, a paren or a brace), and keeps the one that both makes
+the dot reachable in the tree and leaves the fewest parse errors. Everything
+after that is ZLS's ordinary public resolution over an ordinary tree.
+
+**A Tab the backend cannot answer still indents.** The keystroke has already
+diverted by the time "no rows" comes back, so `lspResponse` performs the indent
+the Tab prong skipped — on the condition that the cursor has not moved since,
+so nobody who kept typing gets four spaces landing behind their hands. Without
+that, a dot in a comment, in a string, or on a line nothing can be made of ate
+the keystroke outright. With several cursors Tab never diverts at all: a
+language query is a per-keystroke action inside a per-selection replay, so
+asking would stop the replay dead and collapse the multicursor.
+
+### What it costs, and what it cannot do
+
+Per press, measured by `zig build lspbench` on this repo:
+
+| | ReleaseFast | Debug (what `zig build` installs) |
+|---|---|---|
+| a switch arm in `src/pardes.zig` (12.8k lines) | 8.8 ms | 87 ms |
+| `std.` — 91 candidates, each alias-resolved into the stdlib | 26 ms | 204 ms |
+
+It is a worker thread, so the editor does not block; but the second press of
+Tab joins the first query on the UI thread (`old.cancel(io)` in the shell) and
+that wait is real. Pre-existing and shared by every LSP kind — not this
+feature's to fix, but it is what a fast double-Tab feels like.
+
+Known limitations, in the order you will meet them:
+
+- **`@This()` anywhere in a container makes the whole container unresolvable**,
+ so `var list: std.ArrayList(u8) = .` — the most common decl literal in this
+ codebase — answers nothing. This is not the completion filter: `hover` and a
+ plain field access on the same struct return nothing either. It is the case a
+ user hits first, and it is upstream of everything here.
+- **Only the break AT THE CURSOR is repaired.** Zig's error recovery runs
+ forward, so an unrepaired break earlier in the file swallows the declaration
+ the cursor is in and the answer is empty. While typing you normally have one
+ broken spot, which is the case this works for.
+- **A dependency module** (`@import("vaxis")`) cannot be typed at all, for the
+ same reason `gd` on `vaxis.init` finds nothing.
+- **`error.`** is not handled — the position context is `.error_access`, which
+ no branch claims.
+
## The keymap is helix's, exactly
Verified against `helix-term/src/keymap/default.rs`, not from memory.
@@ -92,6 +162,18 @@ Verified against `helix-term/src/keymap/default.rs`, not from memory.
| `]D` / `[D` | last / first diagnostic | |
| `=` | format | |
| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
+| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined |
+
+Tab is the one key here that is not helix's and not a goto. helix's `Tab`
+completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search`
+buffer and inserts nothing, because that is what a seam returning locations can
+honestly do — see below. It only diverts where an answer is possible: on a
+terminal, in an output buffer, or in a file the backend does not speak
+(`lsp.speaks`, which the core asks and the backend answers), Tab indents
+exactly as it always did. A Tab that silently does nothing would be worse than
+not having the feature. Nothing about the mode changes either — the pane is
+still in insert, so typing goes on and walking the answer with `n` means
+pressing `Esc` first, the same as for every other results buffer.
Ctrl-click rides the ordinary left-click drag rather than firing on the press:
a click does not place the modal cursor until RELEASE, so a query asked at
@@ -136,10 +218,15 @@ nothing else:
```zig
pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void
+pub fn speaks(path: []const u8) bool
pub const supports: std.EnumSet(Kind)
pub const backend_name = "..."
```
+`supports` says what a backend can do; `speaks` says what it can do it TO, and
+exists for the one key that must not be eaten when the answer is no — see the
+Tab note above.
+
`query` runs on a worker thread with no access to the core — everything it may
read is in `req` (`path`, `source` (NUL-terminated), `offset`, `arg`, `root`).
`out` is a plain `std.Io.Writer`: the shell owns the buffer behind it (an
@@ -151,7 +238,7 @@ byte-identical in shape.
## How the implementations are judged
-`zig build lspbench` — same harness, same corpus (pardes's own `src/`), same 17
+`zig build lspbench` — same harness, same corpus (pardes's own `src/`), same 22
probes, every backend.
- **Feature completeness.** Which kinds return rows, and whether the rows