diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui.zig | 38 | ||||
| -rw-r--r-- | src/lsp.zig | 37 | ||||
| -rw-r--r-- | src/lsp_zls.zig | 458 | ||||
| -rw-r--r-- | src/pardes.zig | 124 | ||||
| -rw-r--r-- | src/tty.zig | 21 |
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; } |
