summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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;
}