summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-29 10:20:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitcc173a821bd6fd84fa6e7c2f272b7e049c7a1aaa (patch)
tree23dadf74493c0172ea7fced1dff44d2a4178c752 /src
parent4e642c1d6688baf3b98f269818b66cc7cc194c5f (diff)
downloadpardes-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')
-rw-r--r--src/gui.zig38
-rw-r--r--src/lsp.zig37
-rw-r--r--src/lsp_zls.zig458
-rw-r--r--src/pardes.zig124
-rw-r--r--src/tty.zig21
5 files changed, 542 insertions, 136 deletions
diff --git a/src/gui.zig b/src/gui.zig
index e157ef28..25abe905 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -610,8 +610,9 @@ fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
defer job.free(gpa);
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
+ // the shell owns the result buffer; the backend only ever writes to it
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
pardes.lsp.query(gpa, arena.allocator(), .{
.kind = job.kind,
.path = job.path,
@@ -619,8 +620,8 @@ fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
.offset = job.offset,
.arg = job.arg,
.root = job.root,
- }, &out);
- const rows = gpa.dupe(u8, out.items) catch return;
+ }, &out.writer);
+ const rows = gpa.dupe(u8, out.written()) catch return;
q.push(.{ .lsp = .{ .id = job.id, .rows = rows } });
}
@@ -629,17 +630,19 @@ fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
/// already tolerates a late push after close.
fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void {
const pane = core.panes[e.pane] orelse return;
- const f = pane.file orelse return;
+ // a pane with no file still asks `status` (it is about the backend, not
+ // the buffer): empty path and source, root from the pane's cwd
+ const f = pane.file;
const job = gpa.create(LspJob) catch return;
job.* = .{
.id = e.id,
.kind = e.kind,
.offset = e.offset,
- .path = gpa.dupe(u8, f.path) catch {
+ .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch {
gpa.destroy(job);
return;
},
- .source = gpa.dupeZ(u8, f.content) catch {
+ .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch {
gpa.free(job.path);
gpa.destroy(job);
return;
@@ -650,7 +653,7 @@ fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype)
gpa.destroy(job);
return;
},
- .root = gpa.dupe(u8, std.fs.path.dirname(f.path) orelse "/") catch {
+ .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
gpa.free(job.path);
gpa.free(job.source);
gpa.free(job.arg);
@@ -1572,6 +1575,7 @@ const StdinFeed = struct {
},
.col = @intCast(@max(m.col, 0)),
.row = @intCast(@max(m.row, 0)),
+ .ctrl = m.mods.ctrl,
} });
out.n_events += 1;
}
@@ -1714,12 +1718,18 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
else => return,
};
const mc = mouseCell(g, core, b.x, b.y);
- core.update(.{ .mouse = .{
- .button = button,
- .kind = if (b.down) .press else .release,
- .col = mc.col,
- .row = mc.row,
- } });
+ core.update(.{
+ .mouse = .{
+ .button = button,
+ .kind = if (b.down) .press else .release,
+ .col = mc.col,
+ .row = mc.row,
+ // asked of SDL directly rather than read off g.live_ctrl:
+ // that one is bookkeeping from KEY events, and a ctrl-click
+ // with no key pressed since startup would miss it
+ .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
+ },
+ });
},
c.SDL_EVENT_MOUSE_MOTION => {
const m = sev.motion;
diff --git a/src/lsp.zig b/src/lsp.zig
index fe492c62..536047a3 100644
--- a/src/lsp.zig
+++ b/src/lsp.zig
@@ -50,6 +50,17 @@ pub const Kind = enum {
/// SPC h
select_refs,
+ // The two introspection kinds. A backend that answers nothing is
+ // indistinguishable from a backend that is broken, so these exist to tell
+ // those apart — they are the only Kinds whose answer is ABOUT the backend
+ // rather than about the code.
+ /// SPC l i — configuration, capabilities and the recent-query log
+ status,
+ /// SPC l w — why the query at the cursor answers what it does. Narrates
+ /// the REAL resolution path rather than re-deriving it, so it cannot drift
+ /// away from what `gd` actually did.
+ explain,
+
/// Whether an answer of exactly one row should JUMP rather than open a
/// results buffer. Helix: the five gotos jump on a single location and
/// show a picker on several; a symbol list is always a picker.
@@ -66,6 +77,8 @@ pub const Kind = enum {
return switch (k) {
.hover => "+Hover",
.code_action, .format, .rename => "+Lsp",
+ // prose about the backend, never a list of locations
+ .status, .explain => "+Lsp",
else => "+Search",
};
}
@@ -94,14 +107,13 @@ pub const Req = struct {
/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way
/// every other row in a `+Search` buffer is (and the way look.zig parses one).
pub fn row(
- gpa: std.mem.Allocator,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
path: []const u8,
line: usize,
col: usize,
text: []const u8,
) void {
- out.print(gpa, "{s}:{d}:{d} {s}\n", .{
+ out.print("{s}:{d}:{d} {s}\n", .{
path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"),
}) catch {};
}
@@ -115,14 +127,19 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi
return .{ .line = line, .col = upto.len - bol };
}
-/// Answer `req`, appending rows to `out`. Runs on a worker thread with no
+/// Answer `req`, writing rows to `out`. Runs on a worker thread with no
/// access to the core: everything it may read is in `req`.
///
-/// `arena` is freed wholesale when the query returns; `gpa` owns only what
-/// goes into `out`. Errors are not reported — a backend that cannot answer
-/// appends nothing, and the core treats "no rows" as "no result", which is
-/// also what a language server that is still starting up looks like.
-pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.ArrayList(u8)) void {
+/// `out` is a plain `std.Io.Writer` — 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. Write failures are the
+/// writer's problem; a backend may ignore them.
+///
+/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's
+/// own longer-lived scratch. Errors are not reported — a backend that cannot
+/// answer writes nothing, and the core treats "no rows" as "no result", which
+/// is also what a language server still starting up looks like.
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void {
backend.query(gpa, arena, req, out);
}
@@ -131,7 +148,7 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *s
/// effect, and cannot build ZLS anyway, so there it is the empty backend the
/// base tree shipped with.
const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct {
- pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.ArrayList(u8)) void {}
+ pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {}
pub const supports: std.EnumSet(Kind) = .initEmpty();
};
diff --git a/src/lsp_zls.zig b/src/lsp_zls.zig
index 99d20a10..6f5bbcdd 100644
--- a/src/lsp_zls.zig
+++ b/src/lsp_zls.zig
@@ -23,9 +23,21 @@
//! - `zig_exe_path` is null on purpose: shelling out to the compiler is the
//! thing this backend exists to avoid. `zig_lib_dir` is the build's own
//! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable —
-//! `gd` on `std.mem.count` lands in the real `mem.zig`. The price is that
-//! `@import("builtin")`, `@import("<pkg>")` and `@cImport` resolve to
-//! nothing, silently, because those need a build graph we refuse to run.
+//! `gd` on `std.mem.count` lands in the real `mem.zig`.
+//!
+//! The price is DEPENDENCIES, and it is worth being exact about how far it
+//! goes. ZLS resolves a non-`std`, non-relative import only through a
+//! `BuildFile` whose config came from running `zig build --build-runner`, so
+//! with no compiler it resolves none of them. We paper over ONE case of
+//! that: `gd` on the import string itself (`moduleRoot`, fed by build.zig).
+//! That is a fallback in OUR handler and ZLS cannot see it, so the analyser
+//! still cannot type `vaxis`, and `gd` on `vaxis.init` still finds nothing.
+//! Making member access work means giving ZLS a real build config — either
+//! by letting it run the build runner (a subprocess, and with no cross-query
+//! cache that is once PER KEYPRESS) or by synthesizing a BuildConfig and
+//! reaching into `BuildFile.impl`. Both are real work; neither is smuggled
+//! in here. `@import("builtin")` and `@cImport` are unresolved for the same
+//! reason.
const std = @import("std");
const zls = @import("zls");
const lsp = @import("lsp.zig");
@@ -64,17 +76,142 @@ pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{
.rename,
.format,
.code_action,
+ .status,
+ .explain,
});
+// ------------------------------------------------------------ introspection
+//
+// Everything below exists because of the contract two lines down: this backend
+// never fails loudly. That is right for an editor — a thrown analyser must not
+// take the process with it — but it means a broken backend and a correct one
+// that found nothing look identical from the outside. So every query leaves a
+// record, and `SPC l i` reads them back.
+
+/// One finished query. Fixed-size and inline: this array is process-global and
+/// lives forever, so it must not own heap.
+const LogEntry = struct {
+ used: bool = false,
+ kind: lsp.Kind = .definition,
+ us: u64 = 0,
+ rows: usize = 0,
+ offset: u32 = 0,
+ /// the error `run` returned, or empty when it returned normally. THIS is
+ /// the thing you cannot see any other way.
+ err: [40]u8 = @splat(0),
+ err_len: u8 = 0,
+ /// basename only — a full path would need most of this struct
+ file: [64]u8 = @splat(0),
+ file_len: u8 = 0,
+
+ fn errName(e: *const LogEntry) []const u8 {
+ return e.err[0..e.err_len];
+ }
+ fn fileName(e: *const LogEntry) []const u8 {
+ return e.file[0..e.file_len];
+ }
+};
+
+const log_cap = 24;
+var log_buf: [log_cap]LogEntry = @splat(.{});
+var log_next: usize = 0;
+var log_total: u64 = 0;
+/// 0.16 has no std.Thread.Mutex; the critical sections here are a memcpy, so
+/// spinning on the lock-free one is enough — the same call gui.zig's pty queue
+/// makes. Only WORKER threads touch this; the core never does.
+var log_mu: std.atomic.Mutex = .unlocked;
+
+fn logLock() void {
+ while (!log_mu.tryLock()) std.atomic.spinLoopHint();
+}
+
+fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void {
+ logLock();
+ defer log_mu.unlock();
+ const e = &log_buf[log_next];
+ e.* = .{ .used = true, .kind = req.kind, .us = us, .rows = rows, .offset = req.offset };
+ const base = std.fs.path.basename(req.path);
+ e.file_len = @intCast(@min(base.len, e.file.len));
+ @memcpy(e.file[0..e.file_len], base[0..e.file_len]);
+ e.err_len = @intCast(@min(err.len, e.err.len));
+ @memcpy(e.err[0..e.err_len], err[0..e.err_len]);
+ log_next = (log_next + 1) % log_cap;
+ log_total += 1;
+}
+
+/// A duration cannot go in a snapshot golden — it differs every run. The
+/// snapshot harness sets PARDES_LSP_NOTIME so the two introspection views
+/// print `-` instead, the same way it pins PARDES_DUMP for the dump tests.
+/// Nothing else reads it, and unset (i.e. every real run) shows real numbers.
+fn hideTime() bool {
+ const v = std.c.getenv("PARDES_LSP_NOTIME") orelse return false;
+ return std.mem.span(v).len != 0;
+}
+
+fn nowUs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000;
+}
+
+/// The narration `SPC l w` prints. It is threaded through the REAL resolution
+/// path rather than re-derived beside it: a debug view that reimplements the
+/// logic is a debug view that can disagree with it, and the whole point is to
+/// find out what actually happened.
+const Trace = struct {
+ on: bool = false,
+ arena: std.mem.Allocator,
+ buf: std.ArrayList(u8) = .empty,
+
+ fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
+ if (!t.on) return;
+ t.buf.print(t.arena, fmt ++ "\n", args) catch {};
+ }
+};
+
/// The seam's contract: never fail, never panic, no rows is a legal answer.
/// Every error path in here — OOM, a cancelled io, a file that vanished —
/// collapses to "appended nothing", which the core already treats as "no
/// result". There is deliberately no error rendering.
-pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) void {
- run(gpa, arena, req, out) catch {};
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void {
+ // status reads the log; recording it would push a real query out of a
+ // 24-entry ring every time you looked at it.
+ if (req.kind == .status) return status(arena, req, out) catch {};
+
+ // Rows land in a scratch buffer first, for two reasons a plain Writer
+ // cannot serve: the log wants an exact row count, and `explain` throws the
+ // rows away and prints the narration in their place — neither a count nor
+ // a rewind exists on a Writer you do not own.
+ var scratch: std.Io.Writer.Allocating = .init(gpa);
+ defer scratch.deinit();
+
+ var tr: Trace = .{ .arena = arena, .on = req.kind == .explain };
+ const t0 = nowUs();
+ var err_name: []const u8 = "";
+ run(gpa, arena, req, &scratch.writer, &tr) catch |e| {
+ err_name = @errorName(e);
+ tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name});
+ };
+ const us = nowUs() -| t0;
+ const rows = std.mem.count(u8, scratch.written(), "\n");
+ record(req, us, rows, err_name);
+
+ if (req.kind != .explain) {
+ out.writeAll(scratch.written()) catch {};
+ return;
+ }
+ // the question was never "where is it", it was "what did you do"
+ out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
+ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
+ }) catch {};
+ out.writeAll(tr.buf.items) catch {};
+ if (hideTime())
+ out.print("\n{d} row(s)\n", .{rows}) catch {}
+ else
+ out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {};
}
-fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void {
+fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void {
// The blocking, single-threaded std.Io — the same one look.zig walks
// directories with. ZLS wants an `Io` for every file read; a worker thread
// that is allowed to block wants the one that just does the syscall.
@@ -83,25 +220,35 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
// The two workspace kinds are about the PROJECT, not about this buffer, so
// they answer from any pane and never need the analyser, the store or the
// intern pool — a walk and a parse each.
- switch (req.kind) {
+ // `explain` narrates the definition query; everywhere below dispatches on
+ // the effective kind, so the trace follows the code `gd` really runs.
+ const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind;
+
+ switch (kind) {
.workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out),
.workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out),
// Everything else is about THIS file, and this backend speaks Zig. The
// core does not gate the keymap by file type (a pane is a pane), 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.
- else => if (!std.mem.endsWith(u8, req.path, ".zig")) return,
+ else => if (!std.mem.endsWith(u8, req.path, ".zig")) {
+ tr.note("STOP: not a .zig file ({s}) — this backend only reads Zig", .{
+ if (req.path.len == 0) "no path: this pane has no file behind it" else req.path,
+ });
+ return;
+ },
}
// Pure tree, no analyser.
- if (req.kind == .format) return formatQuery(gpa, arena, req, out);
+ if (kind == .format) return formatQuery(arena, req, out);
var env: std.process.Environ.Map = .init(arena);
var lib_dir: ?std.Build.Cache.Directory = null;
if (zigLibPath()) |p| {
if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| {
lib_dir = .{ .handle = h, .path = p };
- } else |_| {}
- }
+ tr.note("zig lib dir: {s} (open) — `std` imports resolve", .{p});
+ } else |e| tr.note("zig lib dir: {s} FAILED to open ({s}) — nothing in `std` will resolve", .{ p, @errorName(e) });
+ } else tr.note("zig lib dir: NOT SET — nothing in `std` will resolve", .{});
defer if (lib_dir) |d| d.handle.close(io);
var ip: InternPool = try .init(io, gpa);
@@ -131,28 +278,158 @@ fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std
// shell's snapshot, so unsaved edits are what gets analysed.
const uri: Uri = try .fromPath(arena, req.path);
try store.openLspSyncedDocument(uri, req.source);
- const handle = store.getHandle(uri) orelse return;
- if (handle.tree.mode == .zon) return;
+ const handle = store.getHandle(uri) orelse {
+ tr.note("STOP: the document store would not take this buffer", .{});
+ return;
+ };
+ if (handle.tree.mode == .zon) {
+ tr.note("STOP: parsed as .zon, not Zig code — there is nothing to resolve", .{});
+ return;
+ }
+ tr.note("parsed {d} bytes, {d} syntax error(s){s}", .{
+ req.source.len,
+ handle.tree.errors.len,
+ if (handle.tree.errors.len == 0) "" else " — a broken tree resolves badly or not at all",
+ });
var analyser: Analyser = .init(gpa, arena, &store, &ip, handle);
defer analyser.deinit();
const off: usize = @min(req.offset, req.source.len);
- switch (req.kind) {
- .definition, .implementation => try goto(gpa, arena, &analyser, handle, off, .definition, out),
- .declaration => try goto(gpa, arena, &analyser, handle, off, .declaration, out),
- .type_definition => try goto(gpa, arena, &analyser, handle, off, .type_definition, out),
- .hover => try hover(gpa, arena, &analyser, handle, off, out),
+ switch (kind) {
+ .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, out, tr),
+ .declaration => try goto(arena, &analyser, handle, off, .declaration, out, tr),
+ .type_definition => try goto(arena, &analyser, handle, off, .type_definition, out, tr),
+ .hover => try hover(arena, &analyser, handle, off, out),
.document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out),
- .references, .select_refs => try references(gpa, arena, &analyser, handle, off, null, out),
- .rename => try references(gpa, arena, &analyser, handle, off, req.arg, out),
+ .references, .select_refs => try references(arena, &analyser, handle, off, null, out),
+ .rename => try references(arena, &analyser, handle, off, req.arg, out),
.diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out),
.code_action => try codeActions(gpa, arena, &analyser, handle, off, out),
else => {},
}
}
+/// `SPC l i`. What the backend IS: which ZLS, which stdlib, what it will and
+/// will not answer, and the last two dozen queries with their timings and —
+/// the point of the whole exercise — the errors that `query` swallowed.
+fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
+ const io = std.Io.Threaded.global_single_threaded.io();
+ try out.print("backend: {s}\nzls: {s} (compiled in — no server process, no JSON-RPC)\n", .{
+ lsp.backend_name, cfg.zls_version,
+ });
+
+ // The single most common cause of "gd does nothing in std": say whether it
+ // is actually openable, not just what it is set to.
+ if (zigLibPath()) |p| {
+ if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| {
+ var d = h;
+ d.close(io);
+ try out.print("zig lib dir: {s} [OK]\n", .{p});
+ } else |e| try out.print("zig lib dir: {s} [CANNOT OPEN: {s}] — nothing in `std` will resolve\n", .{ p, @errorName(e) });
+ } else try out.print("zig lib dir: NOT SET — nothing in `std` will resolve\n", .{});
+ try out.print("offsets: {s} walk caps: {d} files, {d} rows\n", .{ @tagName(enc), max_files, max_rows });
+ // The dependency module map, which is the answer to "why does gd work on
+ // std and not on @import(\"vaxis\")" — if a name is not on this list, the
+ // build did not import it and nothing can resolve it.
+ try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len});
+ for (cfg.module_names, cfg.module_roots) |n, r| {
+ try out.print(" {s:<14} {s}\n", .{ n, r });
+ }
+ try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path});
+
+ try out.print("\nanswers:", .{});
+ var it = supports.iterator();
+ var n: usize = 0;
+ while (it.next()) |k| : (n += 1) {
+ if (k == .status or k == .explain) continue; // these two are the debugger, not a feature
+ try out.print("{s}{s}", .{ if (n == 0) " " else ", ", @tagName(k) });
+ }
+ try out.print("\nrefuses: ", .{});
+ n = 0;
+ for (std.enums.values(lsp.Kind)) |k| {
+ if (supports.contains(k)) continue;
+ try out.print("{s}{s}", .{ if (n == 0) "" else ", ", @tagName(k) });
+ n += 1;
+ }
+ if (n == 0) try out.print("(nothing)", .{});
+ try out.print("\n", .{});
+
+ // The log. Oldest first, so it reads like a transcript.
+ logLock();
+ defer log_mu.unlock();
+ try out.print("\nlast queries ({d} total, keeping {d}):\n", .{ log_total, log_cap });
+ var shown: usize = 0;
+ for (0..log_cap) |i| {
+ const e = &log_buf[(log_next + i) % log_cap];
+ if (!e.used) continue;
+ shown += 1;
+ if (hideTime()) {
+ try out.print(" {s:<22} {s:<20} @{d:<7} {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), e.fileName(), e.offset, e.rows,
+ if (e.err_len == 0) "" else " ERROR: ", e.errName(),
+ });
+ } else {
+ try out.print(" {s:<22} {s:<20} @{d:<7} {d:>7}us {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), e.fileName(), e.offset, e.us, e.rows,
+ if (e.err_len == 0) "" else " ERROR: ", e.errName(),
+ });
+ }
+ }
+ if (shown == 0) try out.print(" (none yet — press gd somewhere, then ask again)\n", .{});
+ _ = arena;
+}
+
+/// The root source file of a module this build imports, or null.
+///
+/// ZLS answers `@import("std")` from `zig_lib_dir` and a relative `.zig` path
+/// from the filesystem, but ANY OTHER name — every dependency in
+/// build.zig.zon — it can only resolve by running `zig build --build-runner`
+/// to discover the module graph. This backend sets `zig_exe_path = null` on
+/// purpose, so that branch always returned nothing: `gd` worked perfectly into
+/// `std` and silently did nothing on `@import("vaxis")`.
+///
+/// We never needed the compiler for this. build.zig IS the module graph, so it
+/// folds its own `root_mod.import_table` into these two parallel arrays at
+/// configure time. Correct by construction: a dependency added or renamed in
+/// build.zig cannot forget to appear here.
+///
+/// LIMITS, in the order you will hit them:
+/// 1. This resolves the IMPORT STRING only. It is consulted from our own
+/// `goto`, not from inside ZLS, so the analyser still cannot type the
+/// `vaxis` const — `gd` on `vaxis.init` finds nothing. Fixing that means
+/// giving ZLS a BuildConfig; see the note at the top of this file.
+/// 2. These are THIS build's imports. A file inside a dependency importing
+/// that dependency's own internal module name is a miss — that would mean
+/// running ITS build.zig.
+/// 3. A module whose root source is a GENERATED file is absent from the
+/// table: it has no path until make() runs.
+fn moduleRoot(name: []const u8) ?[]const u8 {
+ comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len);
+ for (cfg.module_names, cfg.module_roots) |n, r| {
+ if (std.mem.eql(u8, n, name)) return r;
+ }
+ return null;
+}
+
+/// Why a field access came back empty. Worth its own function because ONE of
+/// the reasons is a known, explainable hole rather than "no idea": if the left
+/// side starts with a dependency module name, the analyser could not type it
+/// because ZLS has no build config, and no amount of staring at the code will
+/// reveal that.
+fn explainFieldMiss(tr: *Trace, lhs: []const u8) void {
+ var head: usize = 0;
+ while (head < lhs.len and (std.ascii.isAlphanumeric(lhs[head]) or lhs[head] == '_')) head += 1;
+ if (head != 0 and moduleRoot(lhs[0..head]) != null) {
+ tr.note(" `{s}` IS a build.zig dependency, and this is the known hole: the module", .{lhs[0..head]});
+ tr.note(" map is consulted by our @import handler, not by ZLS, so the analyser cannot", .{});
+ tr.note(" type it. `gd` on the import string works; `gd` on a member does not.", .{});
+ return;
+ }
+ tr.note(" (a value whose type needs comptime, an unresolved @import, or genuinely absent)", .{});
+}
+
/// The zig lib directory, which is the one thing this backend cannot work out
/// for itself: it is baked in at build time from `b.graph.zig_lib_directory`,
/// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if
@@ -202,12 +479,12 @@ fn lineAt(src: []const u8, want: usize) []const u8 {
/// Emit one row for a token in whatever file it lives in — which for a goto
/// into `std` is not the file the cursor was in, so the path comes off the
/// handle's uri rather than off `req`.
-fn rowForToken(gpa: std.mem.Allocator, arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.ArrayList(u8)) !void {
+fn rowForToken(arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.Io.Writer) !void {
const tree = &th.handle.tree;
if (th.token >= tree.tokens.len) return;
const r = offsets.tokenToRange(tree, th.token, enc);
const path = th.handle.uri.toFsPath(arena) catch return;
- lsp.row(gpa, out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line));
+ lsp.row(out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line));
}
// ---------------------------------------------------------------- goto
@@ -220,32 +497,65 @@ const GotoKind = enum { declaration, definition, type_definition };
/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's
/// own implementation handler does too).
fn goto(
- gpa: std.mem.Allocator,
arena: std.mem.Allocator,
analyser: *Analyser,
handle: *DocumentStore.Handle,
off: usize,
kind: GotoKind,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
+ tr: *Trace,
) !void {
const tree = &handle.tree;
const ctx = try Analyser.getPositionContext(arena, tree, off, true);
+ tr.note("position context: .{s}", .{@tagName(ctx)});
var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
switch (ctx) {
.var_access, .test_doctest_name => {
- const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
+ const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
+ tr.note("STOP: no identifier under the cursor", .{});
+ return;
+ };
const name = offsets.locToSlice(tree.source, name_loc);
- if (std.mem.eql(u8, name, "_")) return;
- if (std.zig.isPrimitive(name)) return;
- const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse return;
+ tr.note("identifier: `{s}` — a plain name, so: innermost scope outward", .{name});
+ if (std.mem.eql(u8, name, "_")) {
+ tr.note("STOP: `_` is the discard, it declares nothing", .{});
+ return;
+ }
+ if (std.zig.isPrimitive(name)) {
+ tr.note("STOP: `{s}` is a builtin primitive — it has no declaration in any file", .{name});
+ return;
+ }
+ const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse {
+ tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name});
+ return;
+ };
try decls.append(arena, d);
},
.field_access => |loc| {
- const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
+ const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
+ tr.note("STOP: no identifier under the cursor", .{});
+ return;
+ };
const name = offsets.locToSlice(tree.source, name_loc);
const held = offsets.locMerge(loc, name_loc);
- const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse return;
+ tr.note("field access: `{s}` on `{s}` — needs the TYPE of the left side", .{
+ name, offsets.locToSlice(tree.source, loc),
+ });
+ // NOTE both misses: a null result AND an empty one. ZLS returns an
+ // empty slice when it typed the left side but found no such member,
+ // and null when it could not type it at all — from the outside both
+ // are "gd did nothing", so both have to be explained.
+ const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse {
+ tr.note("STOP: could not resolve the left side to a type at all", .{});
+ explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
+ return;
+ };
+ if (found.len == 0) {
+ tr.note("STOP: the left side resolved, but nothing named `{s}` was found on it", .{name});
+ explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
+ return;
+ }
try decls.appendSlice(arena, found);
},
.label_access, .label_decl => {
@@ -266,23 +576,43 @@ fn goto(
const loc = ctx.stringLiteralContentLoc(tree.source);
if (loc.start == loc.end) return;
const str = offsets.locToSlice(tree.source, loc);
+ tr.note("import string: \"{s}\"", .{str});
const res = try analyser.store.uriFromImportStr(arena, handle, str);
switch (res) {
- .none => return,
- .one => |u| lsp.row(gpa, out, u.toFsPath(arena) catch return, 0, 0, str),
- .many => |us| for (us) |u| lsp.row(gpa, out, u.toFsPath(arena) catch continue, 0, 0, str),
+ .none => {
+ // ZLS gave up, which for a DEPENDENCY name is not a real
+ // dead end — it just means it wanted the module graph and
+ // we would not start a compiler to get one. Our build.zig
+ // knows the graph and baked it in; consult that.
+ if (moduleRoot(str)) |path| {
+ tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str});
+ lsp.row(out, path, 0, 0, str);
+ return;
+ }
+ tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str});
+ tr.note(" build's own dependencies work; `builtin`, `root` and a dependency's", .{});
+ tr.note(" OWN internal module names need the build graph we do not run.", .{});
+ return;
+ },
+ .one => |u| lsp.row(out, u.toFsPath(arena) catch return, 0, 0, str),
+ .many => |us| for (us) |u| lsp.row(out, u.toFsPath(arena) catch continue, 0, 0, str),
}
return;
},
- else => return,
+ else => {
+ tr.note("STOP: nothing to resolve in a .{s} context — `gd` only answers on", .{@tagName(ctx)});
+ tr.note(" an identifier, a field access, a label, an enum literal or an @import string.", .{});
+ return;
+ },
}
+ tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) });
// One decl that fails to resolve must not swallow the others: a field
// access can name several and a partial answer beats none.
for (decls.items) |d| {
switch (kind) {
- .declaration => try rowForToken(gpa, arena, d.definitionToken(analyser, false) catch continue, out),
- .definition => try rowForToken(gpa, arena, d.definitionToken(analyser, true) catch continue, out),
+ .declaration => try rowForToken(arena, d.definitionToken(analyser, false) catch continue, out),
+ .definition => try rowForToken(arena, d.definitionToken(analyser, true) catch continue, out),
.type_definition => {
// peel error unions / pointers / optionals the way ZLS does,
// then take the container's own name token
@@ -294,7 +624,7 @@ fn goto(
try analyser.resolveOptionalUnwrap(r) orelse break;
}
if (r.typeDefinitionToken()) |th| {
- try rowForToken(gpa, arena, th, out);
+ try rowForToken(arena, th, out);
continue;
}
}
@@ -302,7 +632,7 @@ fn goto(
const t2 = &nd.handle.tree;
const rr = offsets.nodeToRange(t2, nd.node, enc);
const path = nd.handle.uri.toFsPath(arena) catch continue;
- lsp.row(gpa, out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line));
+ lsp.row(out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line));
},
}
}
@@ -315,12 +645,11 @@ fn goto(
/// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped
/// because a plain-text pane has nothing to do with them.
fn hover(
- gpa: std.mem.Allocator,
arena: std.mem.Allocator,
analyser: *Analyser,
handle: *DocumentStore.Handle,
off: usize,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return;
const text = switch (h.contents) {
@@ -330,7 +659,7 @@ fn hover(
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |ln| {
if (std.mem.startsWith(u8, ln, "```")) continue;
- out.print(gpa, "{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return;
+ out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return;
}
}
@@ -344,7 +673,7 @@ fn documentSymbols(
arena: std.mem.Allocator,
path: []const u8,
tree: *const Ast,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc);
var n: usize = 0;
@@ -361,7 +690,7 @@ fn emitSymbols(
syms: []const types.DocumentSymbol,
prefix: []const u8,
n: *usize,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
for (syms) |s| {
if (n.* >= max_rows) return;
@@ -374,7 +703,7 @@ fn emitSymbols(
try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d })
else
name;
- lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, text);
+ lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, text);
if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out);
}
}
@@ -389,7 +718,7 @@ fn workspaceSymbols(
arena: std.mem.Allocator,
io: std.Io,
req: lsp.Req,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
if (req.arg.len == 0) return;
var files: std.ArrayList([]const u8) = .empty;
@@ -413,7 +742,7 @@ fn filterSymbols(
prefix: []const u8,
needle: []const u8,
n: *usize,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
for (syms) |s| {
if (n.* >= max_rows) return;
@@ -423,7 +752,7 @@ fn filterSymbols(
try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name });
if (containsIgnoreCase(s.name, needle)) {
n.* += 1;
- lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, name);
+ lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, name);
}
if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out);
}
@@ -459,13 +788,12 @@ fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool {
/// with the replacement. The seam returns rows, not edits, so `SPC r` shows
/// what would change and changes nothing — an honest half of rename.
fn references(
- gpa: std.mem.Allocator,
arena: std.mem.Allocator,
analyser: *Analyser,
handle: *DocumentStore.Handle,
off: usize,
new_name: ?[]const u8,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
const tree = &handle.tree;
const target = try declAt(arena, analyser, handle, off) orelse return;
@@ -496,7 +824,7 @@ fn references(
try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") })
else
lines.line(r.start.line);
- lsp.row(gpa, out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text);
+ lsp.row(out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text);
}
}
@@ -541,10 +869,10 @@ fn diagnostics(
arena: std.mem.Allocator,
path: []const u8,
tree: *const Ast,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
const n = try treeDiagnostics(gpa, arena, path, tree, out);
- if (n == 0) lsp.row(gpa, out, path, 0, 0, "no diagnostics");
+ if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics");
}
/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it
@@ -569,7 +897,7 @@ fn treeDiagnostics(
arena: std.mem.Allocator,
path: []const u8,
tree: *const Ast,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !usize {
var n: usize = 0;
if (tree.errors.len != 0) {
@@ -582,7 +910,7 @@ fn treeDiagnostics(
const at = tree.tokenStart(e.token);
const lc = lsp.lineCol(tree.source, at);
n += 1;
- lsp.row(gpa, out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{
+ lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{
w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"),
}));
}
@@ -598,7 +926,7 @@ fn treeDiagnostics(
if (em.src_loc == .none) continue;
const sl = bundle.getSourceLocation(em.src_loc);
n += 1;
- lsp.row(gpa, out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{
+ lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{
bundle.nullTerminatedString(em.msg),
}));
}
@@ -614,7 +942,7 @@ fn workspaceDiagnostics(
arena: std.mem.Allocator,
io: std.Io,
req: lsp.Req,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
var files: std.ArrayList([]const u8) = .empty;
try collectZigFiles(arena, io, req.root, &files);
@@ -626,7 +954,7 @@ fn workspaceDiagnostics(
total += treeDiagnostics(gpa, arena, path, &tree, out) catch continue;
if (total >= max_rows) return;
}
- if (total == 0) lsp.row(gpa, out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len}));
+ if (total == 0) lsp.row(out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len}));
}
// ----------------------------------------------------------- code actions
@@ -644,11 +972,11 @@ fn codeActions(
analyser: *Analyser,
handle: *DocumentStore.Handle,
off: usize,
- out: *std.ArrayList(u8),
+ out: *std.Io.Writer,
) !void {
const tree = &handle.tree;
if (tree.errors.len != 0) {
- out.print(gpa, "no code actions: file does not parse\n", .{}) catch {};
+ out.print("no code actions: file does not parse\n", .{}) catch {};
return;
}
var bundle = try astCheck(gpa, "", tree);
@@ -666,9 +994,9 @@ fn codeActions(
builder.generateCodeActionsInRange(at) catch {};
for (builder.actions.items) |a| {
- out.print(gpa, "{s}\n", .{a.title}) catch return;
+ out.print("{s}\n", .{a.title}) catch return;
}
- if (builder.actions.items.len == 0) out.print(gpa, "no code actions\n", .{}) catch {};
+ if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {};
}
// ---------------------------------------------------------------- format
@@ -678,11 +1006,11 @@ fn codeActions(
/// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the
/// honest shape of "format" behind a read-only seam, and it is still the
/// answer to the question the keypress asks.
-fn formatQuery(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void {
+fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
var tree: Ast = try .parse(arena, req.source, .zig);
defer tree.deinit(arena);
if (tree.errors.len != 0) {
- lsp.row(gpa, out, req.path, 0, 0, "cannot format: file does not parse");
+ lsp.row(out, req.path, 0, 0, "cannot format: file does not parse");
return;
}
var buf: std.ArrayList(u8) = .empty;
@@ -690,7 +1018,7 @@ fn formatQuery(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, o
tree.render(arena, &w.writer, .{}) catch return;
const formatted = w.written();
if (std.mem.eql(u8, formatted, req.source)) {
- lsp.row(gpa, out, req.path, 0, 0, "already formatted");
+ lsp.row(out, req.path, 0, 0, "already formatted");
return;
}
var a = std.mem.splitScalar(u8, req.source, '\n');
@@ -705,7 +1033,7 @@ fn formatQuery(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, o
const ys = y orelse "";
if (std.mem.eql(u8, xs, ys)) continue;
n += 1;
- lsp.row(gpa, out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{
+ lsp.row(out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{
std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"),
}));
}
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
diff --git a/src/tty.zig b/src/tty.zig
index 818d8b48..6b43acd3 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -232,6 +232,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
},
.col = @intCast(m.col),
.row = @intCast(m.row),
+ .ctrl = m.mods.ctrl,
} });
},
.paste => |bytes| {
@@ -417,18 +418,19 @@ fn drainEffects(
.lsp => |q| {
if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet
const pane = core.panes[q.pane] orelse continue;
- const f = pane.file orelse continue;
- // snapshot everything the worker may read (see LspJob)
+ // a pane with no file still asks `status` (it is about the backend,
+ // not the buffer): empty path and source, root from the pane's cwd
+ const f = pane.file;
const job = gpa.create(LspJob) catch continue;
job.* = .{
.id = q.id,
.kind = q.kind,
.offset = q.offset,
- .path = gpa.dupe(u8, f.path) catch {
+ .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch {
gpa.destroy(job);
continue;
},
- .source = gpa.dupeZ(u8, f.content) catch {
+ .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch {
gpa.free(job.path);
gpa.destroy(job);
continue;
@@ -439,7 +441,7 @@ fn drainEffects(
gpa.destroy(job);
continue;
},
- .root = gpa.dupe(u8, std.fs.path.dirname(f.path) orelse "/") catch {
+ .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
gpa.free(job.path);
gpa.free(job.source);
gpa.free(job.arg);
@@ -473,8 +475,9 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
defer job.free(gpa);
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
+ // the shell owns the result buffer; the backend only ever writes to it
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
pardes.lsp.query(gpa, arena.allocator(), .{
.kind = job.kind,
.path = job.path,
@@ -482,8 +485,8 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
.offset = job.offset,
.arg = job.arg,
.root = job.root,
- }, &out);
- const rows = gpa.dupe(u8, out.items) catch return;
+ }, &out.writer);
+ const rows = gpa.dupe(u8, out.written()) catch return;
loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows);
return;
}