summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md18
-rw-r--r--src/Output.zig10
-rw-r--r--src/config.zig7
-rw-r--r--src/detached/server.zig42
-rw-r--r--src/dump.zig1
-rw-r--r--src/effect_sources.zig38
-rw-r--r--src/exec.zig50
-rw-r--r--src/gui/gui.zig45
-rw-r--r--src/host_io.zig52
-rw-r--r--src/image.zig8
-rw-r--r--src/lsp/lsp.zig17
-rw-r--r--src/lsp/lsp_client.zig18
-rw-r--r--src/lsp/lsp_zls.zig1522
-rw-r--r--src/main.zig1
-rw-r--r--src/pardes.zig159
-rw-r--r--src/pdf_view.zig18
-rw-r--r--src/tagline.zig135
-rw-r--r--src/terminal.zig33
-rw-r--r--src/tty/tty.zig26
19 files changed, 113 insertions, 2087 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index ca37856e..42f2eb6f 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,7 +1,10 @@
# Changelog
-## Unreleased
+## 0.0.6
+- A middle or right sweep in a column tag or the workspace tag runs or looks
+ up the swept text on release, highlighted while it is held, as in a pane's
+ tag and as in acme; a press used to act on the word under it at once.
- `Config` (`SPC f c`) opens the startup file, `init`, in a pane, or goes
to its pane when it is open. With no file yet, it opens an empty pane of
that name, and Save writes it, making the directory first. The settings
@@ -9,6 +12,19 @@
- The workspace tag reads `… Dump Themes Config Debug Exit`: `Themes` in
place of `NextColor`, which stays a builtin, and `Config` beside it. A
workspace tag you edited, and one a dump kept, stays as it is.
+- F1-F25, Insert and the keypad reach the GUI's terminal panes too: one key
+ map serves the tty, the GUI and the board.
+- A session restored from a dump is built as a fresh one is: its column and
+ workspace tags hold several selections again, and 9P mtimes are real.
+- Zig files use `zls` from `PATH` as a language server like any other; the
+ in-process ZLS backend is gone, and the binary is about 9 MB smaller.
+- Dumps and init files from before 0.0.4 no longer load their old tag
+ formats: a restored pane's tag tail is read only from `tag_tail`, and an old
+ terminal's `TTY` tag or raw output tail is not replayed. `ThemeSel`,
+ `FontSel` and `ColumnTags` are no longer recognised in init files or dumps
+ (`Themes` and `Fonts` are the words).
+- The GUI always embeds its shader sources; the prebuilt GLSL copies are
+ gone. `lspprobe` is `zig build lspbench -- probe`.
## 0.0.5
diff --git a/src/Output.zig b/src/Output.zig
index 69e9644c..0581be41 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -19,7 +19,6 @@ const Pane = panes.Pane;
const File = panes.File;
const Builtin = builtins.registry.Builtin();
-const gui_shader_source_mode = effect_sources.guiShaderSourceMode();
const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {};
@@ -263,9 +262,6 @@ pub fn fromWord(w: []const u8) ?Origin {
if (std.mem.eql(u8, w, config.errors_buffer)) return .errors;
if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links;
if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved;
- // A dump from before the rename names them by their old words.
- if (std.mem.eql(u8, w, "ThemeSel")) return .{ .cmd = .Themes };
- if (builtins.capabilities.font_picker) if (std.mem.eql(u8, w, "FontSel")) return .{ .cmd = .Fonts };
if (std.meta.stringToEnum(Builtin, w)) |b|
if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b };
if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k };
@@ -825,10 +821,6 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
.compiled_default_shell = config.defaultShell(),
- .gui_shader_source_mode = if (gui_shader_source_mode) |mode|
- mode.label()
- else
- null,
.hover_delay_frames = config.look_preview_delay_frames,
.native_images = p.native_images,
.capabilities = builtins.capabilities,
@@ -882,8 +874,6 @@ pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) });
- if (gui_shader_source_mode) |mode|
- try out.writer.print("GUI shader source: {s}\n", .{mode.label()});
try out.writer.writeByte('\n');
for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path});
const content = try out.toOwnedSlice();
diff --git a/src/config.zig b/src/config.zig
index bb033823..a70ae708 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1268,7 +1268,6 @@ pub const Runtime = struct {
platform: []const u8,
theme_name: []const u8,
compiled_default_shell: []const u8,
- gui_shader_source_mode: ?[]const u8 = null,
hover_delay_frames: ?u16,
/// Where dumps go when `DumpDir` is unset; empty without a host
/// filesystem.
@@ -1437,8 +1436,6 @@ pub const Runtime = struct {
"Default shell ($SHELL, else /bin/sh): {s}\n",
.{ context.platform, context.compiled_default_shell },
);
- if (context.gui_shader_source_mode) |mode|
- try out.print("GUI shader source: {s}\n", .{mode});
if (context.hover_delay_frames) |frames|
try out.print("Look hover delay: {d} frames\n", .{frames})
else
@@ -1663,7 +1660,6 @@ pub const Runtime = struct {
.platform = "gui",
.theme_name = "acme",
.compiled_default_shell = "/bin/sh",
- .gui_shader_source_mode = "live GLSL compiled during this build",
.hover_delay_frames = 18,
.native_images = true,
.capabilities = .{
@@ -1802,7 +1798,6 @@ pub const Runtime = struct {
"Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
"Default shell ($SHELL, else /bin/sh): /bin/sh\n",
- "GUI shader source: live GLSL compiled during this build\n",
"Look hover delay: 18 frames\n",
"Native images: on\n",
}) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
@@ -1811,7 +1806,6 @@ pub const Runtime = struct {
var defaults_context = context;
defaults_context.startup_config_path = null;
defaults_context.platform = "tty";
- defaults_context.gui_shader_source_mode = null;
defaults_context.hover_delay_frames = null;
defaults_context.native_images = false;
defaults_context.capabilities = .{
@@ -1833,7 +1827,6 @@ pub const Runtime = struct {
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null);
diff --git a/src/detached/server.zig b/src/detached/server.zig
index de26db13..d8379793 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -587,24 +587,11 @@ pub const Session = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, path, err);
- if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
- if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
- .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
- .pdf => {},
- };
- };
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
+ host_io.writeFile(s.core, &s.watches, pane, path, bytes);
}
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
- const s = of(ctx);
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
- filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
- s.core.setLastDump(path);
+ host_io.writeDump(of(ctx).core, bytes);
}
fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void {
@@ -621,14 +608,7 @@ pub const Session = struct {
fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
const s = of(ctx);
- const config_dir = s.core.opts.config_dir orelse return;
- const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
- s.core.reportError(pane, "dump themes", err);
- return;
- };
- defer s.gpa.free(out_dir);
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ host_io.dumpThemes(s.core, s.io, s.gpa, pane);
}
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
@@ -761,20 +741,8 @@ pub const Session = struct {
fn harvest(s: *Session) void {
const now = monotonicMs();
- for (&s.retired_shells) |*shell| reapShell(&shell.pid, &shell.kill_at, now);
- for (&s.ptys) |*pty| if (pty.fd < 0) reapShell(&pty.pid, &pty.kill_at, now);
- }
-
- fn reapShell(pid: *posix.pid_t, kill_at: *i64, now: i64) void {
- if (pid.* == 0) return;
- const result = libc.waitpid(pid.*, null, libc.W.NOHANG);
- if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) {
- pid.* = 0;
- kill_at.* = 0;
- } else if (kill_at.* != 0 and now >= kill_at.*) {
- _ = libc.kill(pid.*, libc.SIG.KILL);
- kill_at.* = 0;
- }
+ for (&s.retired_shells) |*shell| host_io.reapRetired(&shell.pid, &shell.kill_at, now);
+ for (&s.ptys) |*pty| if (pty.fd < 0) host_io.reapRetired(&pty.pid, &pty.kill_at, now);
}
fn inotify(s: *Session) c_int {
diff --git a/src/dump.zig b/src/dump.zig
index 78c2bd7a..2d3c2593 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -905,7 +905,6 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
restored.command_done = true;
restored.command_status = t.status;
}
- if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
if (revive) p.emitSpawn(i, restored.serial, t.cwd);
break :terminal restored;
},
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 328ce730..f45bf293 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -3,10 +3,7 @@ const build_config = @import("pardes_config");
const config = @import("config.zig");
const filesystem = @import("fs.zig");
-const shader_dir = if (build_config.gui_shader_sources_prebuilt)
- "shaders/prebuilt/"
-else
- "shaders/";
+const shader_dir = "shaders/";
const ui_vertex_path = shader_dir ++ "ui.vert.glsl";
const ui_fragment_path = shader_dir ++ "ui.frag.glsl";
const image_vertex_path = shader_dir ++ "image.vert.glsl";
@@ -23,28 +20,6 @@ const bloom_up_path = shader_dir ++ "bloom-up.frag.glsl";
pub const Backend = @TypeOf(build_config.platform);
pub const backend: Backend = build_config.platform;
-pub const GuiShaderSourceMode = enum {
- live,
- prebuilt_snapshot,
-
- pub fn label(mode: GuiShaderSourceMode) []const u8 {
- return switch (mode) {
- .live => "live GLSL compiled during this build",
- .prebuilt_snapshot => "paired GLSL snapshot for committed SPIR-V",
- };
- }
-};
-
-/// Only the SDL backend consumes GLSL/SPIR-V. Other backends deliberately have
-/// no shader-source mode rather than being mislabeled "live" or "prebuilt".
-pub fn guiShaderSourceMode() ?GuiShaderSourceMode {
- if (backend != .gui) return null;
- return if (build_config.gui_shader_sources_prebuilt)
- .prebuilt_snapshot
- else
- .live;
-}
-
pub const files = switch (backend) {
.tty => [_]filesystem.Source{
.{ .path = "src/tty/panel_compositor.zig", .contents = @embedFile("tty/panel_compositor.zig") },
@@ -123,14 +98,3 @@ test "EffectCode paths resolve uniquely to this backend's archived inputs" {
try std.testing.expectEqual(backend == .gui, filesystem.sourceBytes("src/gui/gui.zig") != null);
try std.testing.expectEqual(backend == .macos, filesystem.sourceBytes("src/macos/Sources/PardesView.swift") != null);
}
-
-test "shader source provenance is GUI-only and follows the build input" {
- if (comptime backend == .gui) {
- const expected: GuiShaderSourceMode = if (build_config.gui_shader_sources_prebuilt)
- .prebuilt_snapshot
- else
- .live;
- try std.testing.expectEqual(expected, guiShaderSourceMode().?);
- try std.testing.expect(expected.label().len > 0);
- } else try std.testing.expect(guiShaderSourceMode() == null);
-}
diff --git a/src/exec.zig b/src/exec.zig
index 221de105..8c062f66 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -870,58 +870,10 @@ pub fn applyStartupConfig(p: *Pardes) void {
defer p.announce = true;
var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
while (lines.next()) |line| {
- if (executeBuiltinLine(p, p.active, line)) continue;
- // A setting pardes no longer has is said, once, and changes nothing.
- const word = commandText(line);
- const head = word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len];
- if (std.mem.eql(u8, head, "ColumnTags"))
- p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored");
- // A word renamed runs as its new name, and says so, once a line.
- for ([_][2][]const u8{ .{ "ThemeSel", "Themes" }, .{ "FontSel", "Fonts" } }) |renamed| if (std.mem.eql(u8, head, renamed[0])) {
- _ = executeBuiltinLine(p, p.active, renamed[1]);
- var said: [64]u8 = undefined;
- p.setMessage(p.active, std.fmt.bufPrint(&said, "{s}: renamed to {s}", .{ renamed[0], renamed[1] }) catch "renamed");
- };
+ _ = executeBuiltinLine(p, p.active, line);
}
}
-test "an init file's ThemeSel runs as Themes and says it was renamed" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24, .startup_config = "ThemeSel\n" });
- defer p.deinit();
- var listed = false;
- for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| {
- listed = listed or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Themes });
- };
- try std.testing.expect(listed);
- // In the session's directory, not `/`.
- for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output != null)
- try std.testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(f.path).?);
- var said = false;
- for (p.panes) |slot| if (slot) |pane| {
- said = said or std.mem.eql(u8, pane.msg[0..pane.msg_len], "ThemeSel: renamed to Themes");
- };
- try std.testing.expect(said);
- // The panes there at boot are the log's first records, before what the
- // init file said.
- const th = @import("ninep/testing.zig");
- const log = @intFromEnum(pardes.ctlfs.TopFile.log);
- const h = th.call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
- defer _ = th.call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
- const text = th.call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
- try std.testing.expectStringStartsWith(text, "new ");
-}
-
-test "an init file's retired ColumnTags line is said and ignored" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- p.opts.startup_config = "ColumnTags\nWrap off\n";
- applyStartupConfig(p);
- try std.testing.expect(!p.settings.wrap);
- try std.testing.expect(p.columnBarHeight() > 0);
- const pane = p.panes[p.active].?;
- try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]);
-}
-
pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
runBuiltinFrom(p, b, id, txt, arg, false);
}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 8b100cd8..8fd8d360 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4060,9 +4060,9 @@ const Shell = struct {
fn reap(s: *Shell) void {
const now = shellClock();
- for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now);
+ for (&s.retired_shells) |*child| host_io.reapRetired(&child.pid, &child.kill_at, now);
for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) {
- reapShell(&pt.pid, &pt.kill_at, now);
+ host_io.reapRetired(&pt.pid, &pt.kill_at, now);
if (pt.pid == 0) slot.* = null;
};
}
@@ -4085,7 +4085,7 @@ const Shell = struct {
_ = libc.close(pt.fd);
pt.fd = -1;
const now = shellClock();
- reapShell(&pt.pid, &pt.kill_at, now);
+ host_io.reapRetired(&pt.pid, &pt.kill_at, now);
if (pt.pid == 0) {
s.ptys[pane] = null;
return;
@@ -4662,25 +4662,11 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
- filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, path, err);
- if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| {
- if (std.mem.eql(u8, f.path, path))
- if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) {
- .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
- .pdf => {},
- };
- };
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
+ host_io.writeFile(s.core, s.watches, pane, path, bytes);
}
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
- const s = shellOf(ctx);
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
- filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
- s.core.setLastDump(path);
+ host_io.writeDump(shellOf(ctx).core, bytes);
}
fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void {
@@ -4704,14 +4690,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void {
fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
const s = shellOf(ctx);
- const config_dir = s.core.opts.config_dir orelse return;
- const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
- s.core.reportError(pane, "dump themes", err);
- return;
- };
- defer s.gpa.free(out_dir);
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ host_io.dumpThemes(s.core, s.io, s.gpa, pane);
}
fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void {
@@ -4826,18 +4805,6 @@ fn shellClock() i64 {
return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms);
}
-fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void {
- if (pid.* <= 0) return;
- const result = libc.waitpid(pid.*, null, libc.W.NOHANG);
- if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) {
- pid.* = 0;
- kill_at.* = 0;
- } else if (kill_at.* != 0 and now >= kill_at.*) {
- _ = libc.kill(pid.*, libc.SIG.KILL);
- kill_at.* = 0;
- }
-}
-
fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 {
if (!std.math.isFinite(raw_y)) return pending;
const next = pending - raw_y;
diff --git a/src/host_io.zig b/src/host_io.zig
index 05ccca09..ac25e63c 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -5,6 +5,7 @@ const libc = std.c;
const pardes = @import("pardes.zig");
const ninep_io = @import("9p_io.zig");
const filesystem = @import("fs.zig");
+const file_watch = @import("file_watch.zig");
pub const Host = struct {
ctx: ?*anyopaque = null,
@@ -2522,3 +2523,54 @@ test "a full-screen program takes the tty until it quits" {
}
if (ran == 0) return error.SkipZigTest;
}
+
+// ---- what every host's vtable does alike ----
+
+/// A Save's write: the file written through the core's filesystem, the
+/// pane's watch told the new text is the editor's own (so it is no outside
+/// change), and "saved" said on the pane.
+pub fn writeFile(core: *pardes.Pardes, watches: *file_watch.Table, pane: u8, path: []const u8, bytes: []const u8) void {
+ filesystem.write(core, path, bytes) catch |err|
+ return core.saveFailed(pane, path, err);
+ if (core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
+ if (watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
+ .pdf => {},
+ };
+ };
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(pane, pardes.Messages.Message.stamp(&mbuf, "saved", path));
+}
+
+/// A Dump's write, into the DumpDir.
+pub fn writeDump(core: *pardes.Pardes, bytes: []const u8) void {
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf, core.settings.dump_dir.get()) orelse return core.dumpFailed(core.settings.dump_dir.get(), error.NoDumpDirectory);
+ filesystem.write(core, path, bytes) catch |err| return core.dumpFailed(path, err);
+ core.setLastDump(path);
+}
+
+/// DumpThemes: the built-in themes written below the config directory.
+pub fn dumpThemes(core: *pardes.Pardes, io: std.Io, gpa: std.mem.Allocator, pane: u8) void {
+ const config_dir = core.opts.config_dir orelse return;
+ const out_dir = pardes.config.User.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| {
+ core.reportError(pane, "dump themes", err);
+ return;
+ };
+ defer gpa.free(out_dir);
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(pane, pardes.Messages.Message.stamp(&mbuf, "dumped themes", out_dir));
+}
+
+/// A retired shell reaped once it has exited, killed once its grace is up.
+pub fn reapRetired(pid: *std.c.pid_t, kill_at: *i64, now: i64) void {
+ if (pid.* <= 0) return;
+ const result = std.c.waitpid(pid.*, null, std.c.W.NOHANG);
+ if (result > 0 or (result < 0 and std.c.errno(result) == .CHILD)) {
+ pid.* = 0;
+ kill_at.* = 0;
+ } else if (kill_at.* != 0 and now >= kill_at.*) {
+ _ = std.c.kill(pid.*, std.c.SIG.KILL);
+ kill_at.* = 0;
+ }
+}
diff --git a/src/image.zig b/src/image.zig
index 7b081ec6..936fe19a 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -916,14 +916,6 @@ pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 {
);
}
-pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 {
- const lead = config.tag_image ++ " ";
- if (!std.mem.startsWith(u8, saved_tag, lead)) return null;
- const path_at = lead.len;
- if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null;
- return saved_tag[0 .. path_at + state.path.len];
-}
-
fn ensureDecoded(p: *pardes.Pardes, state: *State) void {
if (state.tried) return;
state.tried = true;
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 7e5595fd..43f94f95 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -33,12 +33,14 @@ pub const Req = struct {
root: []const u8 = "",
};
-const backends = if (@import("pardes_config").zls_backend)
- .{ @import("lsp_zls.zig"), @import("lsp_client.zig") }
+/// Every language, Zig's (zls) included, through the protocol client, which
+/// runs its server as a child process. None on the web or the board.
+const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4)
+ .{}
else
- .{};
+ .{@import("lsp_client.zig")};
-pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc";
+pub const backend_name = "lsp-client";
pub const supports: std.EnumSet(Kind) = blk: {
var s: std.EnumSet(Kind) = .initEmpty();
@@ -53,6 +55,7 @@ pub fn speaks(path: []const u8) bool {
pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void {
if (req.kind == .status) {
+ try out.print("backend: {s}\n", .{backend_name});
inline for (backends) |b| try b.query(gpa, arena, req, out);
return;
}
@@ -77,7 +80,7 @@ pub fn none(out: *std.Io.Writer, comptime fmt: []const u8, args: anytype) std.Io
// Called on server reader threads; the sink must copy text before returning.
pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void {
- if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb);
+ inline for (backends) |b| b.setStatusSink(ctx, cb);
}
pub fn rel(base: []const u8, path: []const u8) []const u8 {
@@ -164,7 +167,7 @@ test "a question no backend answers for a file says why rather than find nothing
defer arena.deinit();
var buf: [256]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
- // The in-process ZLS answers no call hierarchy for a .zig file.
- try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/main.zig", .source = "", .offset = 0 }, &out);
+ // A file no server speaks for is answered, not left empty.
+ try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/notes.txt", .source = "", .offset = 0 }, &out);
try std.testing.expectStringStartsWith(out.buffered(), "@none ");
}
diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig
index 7c1980f6..7704d0ad 100644
--- a/src/lsp/lsp_client.zig
+++ b/src/lsp/lsp_client.zig
@@ -1,9 +1,8 @@
//! A real Language Server Protocol client: child processes spoken to over
//! JSON-RPC 2.0 with `Content-Length` framing. Nothing here knows any single
//! language — `specs` is a table of (binary, languageId, extensions, root
-//! markers), and rust-analyzer, clangd and gopls are rows in it. The in-process
-//! ZLS backend keeps `.zig`; this file is every language pardes highlights but
-//! could not answer questions about.
+//! markers), and zls, rust-analyzer, clangd and gopls are rows in it: every
+//! language pardes answers questions about.
//!
//! THE PROCESS LIFECYCLE IS THE DESIGN. `lsp.query` is a synchronous call on a
//! worker thread, and a language server costs tens of milliseconds to start
@@ -104,6 +103,14 @@ const Spec = struct {
pub const specs = [_]Spec{
.{
+ .name = "zls",
+ .bin = "zls",
+ .lang = "zig",
+ .exts = &.{".zig"},
+ .markers = &.{ "build.zig", "build.zig.zon" },
+ .env = "PARDES_LSP_ZIG",
+ },
+ .{
.name = "rust-analyzer",
.bin = "rust-analyzer",
.lang = "rust",
@@ -147,8 +154,7 @@ pub const specs = [_]Spec{
},
};
-/// Everything, including the four hierarchy kinds the in-process backend has
-/// no analyser for. Whether one SERVER can answer is a capability question
+/// Everything. Whether one SERVER can answer is a capability question
/// answered per connection; a kind its server never advertised simply returns
/// no rows, which the harness reports as CLAIMED-EMPTY per language — honest,
/// since the claim here is about the protocol, not about every server.
@@ -2175,9 +2181,9 @@ test "rootOf takes the top-most marker and falls back to git then dir" {
test "specFor routes extensions and honours the disable env" {
try std.testing.expect(specFor("/x/main.rs") != null);
- try std.testing.expect(specFor("/x/main.zig") == null);
try std.testing.expect(specFor("/x/README.md") == null);
try std.testing.expectEqualStrings("rust-analyzer", specs[specFor("/x/main.rs").?].name);
+ if (getenv("PARDES_LSP_ZIG") == null) try std.testing.expectEqualStrings("zls", specs[specFor("/x/main.zig").?].name);
}
test "the transport this host actually gives us is a pair, and both ends are close-on-exec" {
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
deleted file mode 100644
index 2311ec47..00000000
--- a/src/lsp/lsp_zls.zig
+++ /dev/null
@@ -1,1522 +0,0 @@
-//! ZLS as a LIBRARY, not a language server.
-//!
-//! There is no subprocess, no JSON-RPC, no `initialize` handshake and no
-//! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a
-//! token; the whole "protocol" is a struct field. ZLS's build.zig already
-//! publishes its guts as an importable module (`b.modules.put("zls", ...)`),
-//! and everything under `features/` that is not welded to `*Server` is a plain
-//! function over (analyser, arena, handle, offset) — so the parts we want are
-//! callable and the parts we don't want cost nothing.
-//!
-//! What that buys and what it costs:
-//!
-//! - Cold IS warm. There is no server to start, no workspace to index, no
-//! handshake to wait for. The first `gd` after launch pays for parsing this
-//! file and whatever it imports, and nothing else.
-//! - It also means NOTHING IS CACHED between queries. Every call builds a
-//! DocumentStore, resolves imports, and throws it all away, because `query`
-//! is handed an arena that dies on return and a `req` that is a snapshot of
-//! a buffer the user is still typing into. A cache would have to be a global
-//! with its own mutex and its own invalidation story; that is a real design,
-//! not a line of code, and it is the obvious next step rather than something
-//! smuggled in here.
-//! - `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 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");
-const cfg = @import("pardes_config");
-
-const Analyser = zls.Analyser;
-const DocumentStore = zls.DocumentStore;
-const InternPool = zls.analyser.InternPool;
-const offsets = zls.offsets;
-const Uri = zls.Uri;
-const types = zls.lsp.types;
-const Ast = std.zig.Ast;
-
-/// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol
-/// says; we are not on a wire, and `+Search` rows are byte columns.
-const enc: offsets.Encoding = .@"utf-8";
-
-/// Guards on the workspace walks (workspace symbols / workspace diagnostics).
-/// A language query runs on every keypress of `SPC S`; it may not turn into an
-/// unbounded tree walk because someone opened a file in `/`.
-const max_files = 512;
-const max_rows = 2000;
-const max_output_bytes = max_rows * 4096;
-const max_trace_bytes = 32 * 1024;
-const max_code_action_bytes = max_rows * 1024;
-
-pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{
- .definition,
- .declaration,
- .type_definition,
- .implementation,
- .references,
- .select_refs,
- .hover,
- .document_symbols,
- .workspace_symbols,
- .diagnostics,
- .workspace_diagnostics,
- .rename,
- .format,
- .code_action,
- .completion,
- .status,
- .explain,
-});
-
-/// The language gate, hoisted out of `run` so the core can ask it too: a `.zig`
-/// file and nothing else. `gd` in a README has to find nothing rather than
-/// parse prose as Zig, and insert-mode Tab has to keep indenting there rather
-/// than divert into a query that could never answer.
-pub fn speaks(path: []const u8) bool {
- return std.mem.endsWith(u8, path, ".zig");
-}
-
-// ------------------------------------------------------------ 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_NOTIME so the two introspection views print `-`
-/// instead, the same way it pins PARDES_DUMP for the dump tests. ONE flag,
-/// meaning "no wall clock in user-visible text": message.zig reads the same one
-/// to blank the transient message row's `HH:MM:SS`. Unset — i.e. every real run
-/// — shows real numbers.
-fn hideTime() bool {
- const v = std.c.getenv("PARDES_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,
- buf: [max_trace_bytes]u8 = undefined,
- len: usize = 0,
-
- fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
- if (!t.on or t.len == t.buf.len) return;
- var w: std.Io.Writer = .fixed(t.buf[t.len..]);
- w.print(fmt ++ "\n", args) catch {};
- t.len += w.buffered().len;
- }
-
- fn written(t: *const Trace) []const u8 {
- return t.buf[0..t.len];
- }
-};
-
-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);
-
- const scratch_buf = try gpa.alloc(u8, max_output_bytes);
- defer gpa.free(scratch_buf);
- var scratch: std.Io.Writer = .fixed(scratch_buf);
-
- var tr: Trace = .{ .on = req.kind == .explain };
- const t0 = nowUs();
- var err_name: []const u8 = "";
- var failure: ?anyerror = null;
- run(gpa, arena, req, &scratch, &tr) catch |e| {
- failure = e;
- err_name = @errorName(e);
- tr.note("ERROR: {s}", .{err_name});
- };
- const us = nowUs() -| t0;
- const rows = std.mem.count(u8, scratch.buffered(), "\n");
- record(req, us, rows, err_name);
-
- if (req.kind != .explain) {
- if (failure) |err| return err;
- try out.writeAll(scratch.buffered());
- return;
- }
- try 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),
- });
- try out.writeAll(tr.written());
- if (hideTime())
- try out.print("\n{d} row(s)\n", .{rows})
- else
- try out.print("\n{d} row(s) in {d}us\n", .{ rows, us });
-}
-
-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.
- const io = std.Io.Threaded.global_single_threaded.io();
-
- // 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.
- // `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 (!speaks(req.path)) {
- 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 (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 };
- 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);
- defer ip.deinit(gpa);
-
- var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa };
- defer diags.deinit();
-
- var store: DocumentStore = .{
- .io = io,
- .allocator = gpa,
- .config = .{
- .environ_map = &env,
- // no compiler subprocess: that is the entire point of this backend
- .zig_exe_path = null,
- .zig_lib_dir = lib_dir,
- .build_runner_path = null,
- .builtin_path = null,
- .global_cache_dir = null,
- .wasi_preopens = {},
- },
- .diagnostics_collection = &diags,
- };
- defer store.deinit();
-
- // The buffer the user is looking at, not the file on disk: `source` is the
- // shell's snapshot, so unsaved edits are what gets analysed — except for a
- // completion, which is asked ABOUT a half-typed line and has to repair it
- // first (see completionSource).
- const uri: Uri = try .fromPath(arena, req.path);
- try store.openLspSyncedDocument(uri, if (kind == .completion) completionSource(arena, req, tr) else req.source);
- 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);
-
- // the rows of the kinds that only ever name THIS file spell it once
- const here = lsp.rel(req.root, req.path);
-
- switch (kind) {
- .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, req.root, out, tr),
- .declaration => try goto(arena, &analyser, handle, off, .declaration, req.root, out, tr),
- .type_definition => try goto(arena, &analyser, handle, off, .type_definition, req.root, out, tr),
- .hover => try hover(arena, &analyser, handle, off, out),
- .document_symbols => try documentSymbols(gpa, arena, here, &handle.tree, out),
- .references, .select_refs => try references(arena, &analyser, handle, off, null, req.root, out),
- .rename => try references(arena, &analyser, handle, off, req.arg, req.root, out),
- .diagnostics => try diagnostics(gpa, arena, here, &handle.tree, out),
- .code_action => try codeActions(gpa, arena, &analyser, handle, off, out),
- .completion => try completion(arena, &analyser, handle, off, req.root, req.source, out, tr),
- 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.
- // pardes's own build's imports: listed for a file in its tree, none for
- // any other project's file, which does not have them.
- if (inProject(req.path)) {
- 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 });
- }
- } else try out.print("\ndependency imports gd can follow: none (this file is not in the tree pardes was built from)\n", .{});
- 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.
-/// Whether `path` is in the tree pardes was built from, whose build's
-/// imports `cfg.module_names` are.
-fn inProject(path: []const u8) bool {
- const project = std.mem.trimEnd(u8, cfg.project_root, "/");
- return project.len > 0 and path.len > project.len and std.mem.startsWith(u8, path, project) and path[project.len] == '/';
-}
-
-test "only a file in pardes's own tree is told pardes's dependency imports" {
- var buf: [4096]u8 = undefined;
- const inside = try std.fmt.bufPrint(&buf, "{s}/src/main.zig", .{std.mem.trimEnd(u8, cfg.project_root, "/")});
- try std.testing.expect(inProject(inside));
- try std.testing.expect(!inProject("/tmp/elsewhere/main.zig"));
-}
-
-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
-/// the user moved it. Without this, `gd` on anything in `std` finds nothing.
-fn zigLibPath() ?[]const u8 {
- if (std.c.getenv("ZIG_LIB_DIR")) |p| {
- const s = std.mem.span(p);
- if (s.len != 0) return s;
- }
- if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir;
- return null;
-}
-
-// ---------------------------------------------------------------- rows
-
-/// Byte offset -> line, without rescanning the file per row. Built once per
-/// query over the file we emit many rows from (symbols, references,
-/// diagnostics); a goto that emits two rows just scans.
-const Lines = struct {
- starts: []const u32,
- src: []const u8,
-
- fn build(arena: std.mem.Allocator, src: []const u8) !Lines {
- const count = std.mem.countScalar(u8, src, '\n') + 1;
- const starts = try arena.alloc(u32, count);
- starts[0] = 0;
- var n: usize = 1;
- for (src, 0..) |c, i| {
- if (c != '\n') continue;
- starts[n] = @intCast(i + 1);
- n += 1;
- }
- return .{ .starts = starts, .src = src };
- }
-
- fn line(l: Lines, n: usize) []const u8 {
- if (n >= l.starts.len) return "";
- const a = l.starts[n];
- const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len;
- return l.src[a..@min(b, l.src.len)];
- }
-};
-
-/// The source line at a 0-based line number, scanned. For the one-or-two-row
-/// answers (a goto lands in a file we opened only to read one line out of).
-fn lineAt(src: []const u8, want: usize) []const u8 {
- var it = std.mem.splitScalar(u8, src, '\n');
- var i: usize = 0;
- while (it.next()) |l| : (i += 1) if (i == want) return l;
- return "";
-}
-
-/// 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`. `base` is `req.root`, the directory the
-/// row is written relative to (lsp.rel).
-fn rowForToken(arena: std.mem.Allocator, base: []const u8, 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 = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return);
- try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line));
-}
-
-// ---------------------------------------------------------------- goto
-
-const GotoKind = enum { declaration, definition, type_definition };
-
-/// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the
-/// linkSupport branch: dispatch on the position context, resolve to a decl,
-/// resolve the decl to a token, print the token's line. The five helix gotos
-/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's
-/// own implementation handler does too).
-fn goto(
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
- kind: GotoKind,
- /// req.root: what the rows' paths are written relative to (lsp.rel)
- base: []const 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)});
-
- const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows);
- var decl_count: usize = 0;
- switch (ctx) {
- .var_access, .test_doctest_name => {
- 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);
- 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;
- };
- decls[decl_count] = d;
- decl_count += 1;
- },
- .field_access => |loc| {
- 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);
- 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;
- }
- decl_count = @min(found.len, decls.len);
- @memcpy(decls[0..decl_count], found[0..decl_count]);
- },
- .label_access, .label_decl => {
- const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
- const name = offsets.locToSlice(tree.source, name_loc);
- const d = try Analyser.lookupLabel(handle, name, off) orelse return;
- decls[decl_count] = d;
- decl_count += 1;
- },
- .enum_literal => {
- const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
- const name = offsets.locToSlice(tree.source, name_loc);
- const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return;
- decls[decl_count] = d;
- decl_count += 1;
- },
- // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports
- // resolve — a package name needs the build graph we do not run.
- .import_string_literal, .embedfile_string_literal => {
- 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 => {
- // 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});
- try lsp.row(out, lsp.rel(base, 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| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str),
- .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str),
- }
- 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", .{ decl_count, @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[0..decl_count]) |d| {
- switch (kind) {
- .declaration => try rowForToken(arena, base, d.definitionToken(analyser, false) catch continue, out),
- .definition => try rowForToken(arena, base, 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
- if (try d.resolveType(analyser)) |ty| {
- var r = ty;
- while (true) {
- r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse
- try analyser.resolveDerefType(r) orelse
- try analyser.resolveOptionalUnwrap(r) orelse break;
- }
- if (r.typeDefinitionToken()) |th| {
- try rowForToken(arena, base, th, out);
- continue;
- }
- }
- const nd = try d.typeDeclarationNode() orelse continue;
- const t2 = &nd.handle.tree;
- const rr = offsets.nodeToRange(t2, nd.node, enc);
- const path = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue);
- try lsp.sourceSpanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line));
- },
- }
- }
-}
-
-// ------------------------------------------------------------ completion
-
-/// The text a completion query actually analyses.
-///
-/// The user has just typed the `.`, so the buffer DOES NOT PARSE, and not
-/// locally: `switch (e) { . }` loses the entire switch to the parser's error
-/// recovery — the tree keeps the function's block and nothing inside it —
-/// which takes with it every ancestor an expected-type resolution needs. ZLS
-/// answers this with a private token scanner (`getSwitchOrStructInitContext`,
-/// ~200 lines welded to a `*Server`) that re-derives the context by hand. This
-/// backend instead makes the tree PARSE, by splicing a placeholder in after
-/// the dot. Six spellings, because a half-typed line is short of two different
-/// things — the identifier the dot needs, and whatever the user has not closed
-/// yet — and the second half is not optional: a missing closer makes Zig's
-/// recovery discard the whole enclosing declaration, which no placeholder AT
-/// the dot can survive.
-///
-/// `_p => {},` a switch prong: not a prong at all without its arrow, so
-/// no bare identifier can rescue one
-/// `_p;` a statement or declaration still missing its terminator,
-/// which is what `const z: E = .` is the instant it is typed
-/// `_p` everything already closed: `f(.)`, `x = .;`, `.a = .`
-/// `_p => {}, }` ...and the same three again for a construct still hanging
-/// `_p)` open — `switch (e) {`, `g(`, `.{` with no closer yet,
-/// `_p }` which is what the first second of typing looks like
-///
-/// No spelling may contain a NEWLINE: the rows depend on line numbers being
-/// identical between the repaired copy and the user's buffer (see `completion`).
-///
-/// Choosing between them: reachability (is the dot now an `enum_literal` the
-/// tree can be walked down to from the root) is a hard FILTER, the parse-error
-/// count RANKS what survives it, the earlier spelling wins a tie, and a
-/// candidate at ZERO errors ends the search. A buffer that already parses is
-/// left alone, and a dot no spelling can reach is the seam's normal "no
-/// result".
-/// ponytail: one parse per spelling; caching the base parse is the obvious
-/// next step if it ever shows up.
-fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]const u8 {
- if (req.offset == 0 or req.offset > req.source.len) return req.source;
- if (req.source[req.offset - 1] != '.') return req.source;
- var base: Ast = Ast.parse(arena, req.source, .zig) catch return req.source;
- // Nothing to repair: a bare `.` is always a parse error, so a clean tree
- // means the dot is already part of a node — the placeholder's whole job.
- if (base.errors.len == 0) return req.source;
- // A field access resolves off the position context and its own tokenizer,
- // never off the tree, so `foo.` needs no placeholder and pays no shift.
- const ctx = Analyser.getPositionContext(arena, &base, req.offset, false) catch return req.source;
- if (ctx != .enum_literal) return req.source;
-
- var best = req.source;
- var chose: []const u8 = "";
- var fewest: usize = std.math.maxInt(usize);
- for ([_][]const u8{ "_p => {},", "_p;", "_p", "_p => {}, }", "_p)", "_p }" }) |fill| {
- const cand = std.fmt.allocPrintSentinel(arena, "{s}{s}{s}", .{
- req.source[0..req.offset], fill, req.source[req.offset..],
- }, 0) catch continue;
- var t: Ast = Ast.parse(arena, cand, .zig) catch continue;
- if (t.errors.len >= fewest) continue;
- const nodes = zls.ast.nodesOverlappingIndex(arena, &t, req.offset) catch continue;
- if (nodes.len < 2 or t.nodeTag(nodes[0]) != .enum_literal) continue;
- fewest = t.errors.len;
- best = cand;
- chose = fill;
- if (fewest == 0) break;
- }
- if (chose.len == 0)
- tr.note("STOP: no placeholder made the dot reachable in the tree ({d} parse error(s))", .{base.errors.len})
- else
- tr.note("placeholder `{s}`: the dot is an enum_literal again, {d} parse error(s) left (was {d})", .{ chose, fewest, base.errors.len });
- return best;
-}
-
-/// ZLS's collector takes a growable list. This equivalent writes directly into
-/// the query's row-bounded slice and stops in declaration order when it fills.
-fn collectContainerDecls(
- analyser: *Analyser,
- container_type: Analyser.Type,
- original_handle: *DocumentStore.Handle,
- instance_access: bool,
- out: []Analyser.DeclWithHandle,
- len: *usize,
-) !void {
- const info = switch (container_type.data) {
- .container => |info| info,
- .either => |entries| {
- for (entries) |entry| {
- if (len.* == out.len) return;
- const ty: Analyser.Type = .{
- .data = entry.type_data,
- .is_type_val = container_type.is_type_val,
- };
- try collectContainerDecls(analyser, ty, original_handle, instance_access, out, len);
- }
- return;
- },
- else => return,
- };
- const container_scope = info.scope_handle;
- const handle = container_scope.handle;
- const tree = &handle.tree;
- const document_scope = try handle.getDocumentScope();
- const main_token = tree.nodeMainToken(container_scope.toNode());
- const is_enum = tree.tokenTag(main_token) == .keyword_enum;
-
- for (document_scope.getScopeDeclarationsConst(container_scope.scope)) |decl_index| {
- if (len.* == out.len) return;
- const decl = document_scope.declarations.get(@intFromEnum(decl_index));
- const candidate: Analyser.DeclWithHandle = .{
- .decl = decl,
- .handle = handle,
- .container_type = container_type,
- };
- if (handle != original_handle and !candidate.isPublic()) continue;
-
- switch (decl) {
- .ast_node => |node| switch (tree.nodeTag(node)) {
- .container_field_init, .container_field_align, .container_field => {
- if (is_enum) {
- if (instance_access) continue;
- const field_name = offsets.tokenToSlice(tree, tree.nodeMainToken(node));
- if (std.mem.eql(u8, field_name, "_")) continue;
- } else if (!instance_access) continue;
- },
- .fn_proto,
- .fn_proto_multi,
- .fn_proto_one,
- .fn_proto_simple,
- .fn_decl,
- .global_var_decl,
- .local_var_decl,
- .simple_var_decl,
- .aligned_var_decl,
- => {
- if (instance_access) {
- const alias_type = try candidate.resolveType(analyser) orelse continue;
- const func_ty = try analyser.resolveFuncProtoOfCallable(alias_type) orelse continue;
- if (!analyser.firstParamIs(func_ty, .{
- .data = .{ .container = info },
- .is_type_val = true,
- })) continue;
- }
- },
- else => unreachable,
- },
- .label => continue,
- else => {},
- }
- out[len.*] = candidate;
- len.* += 1;
- }
-}
-
-/// Tab after a `.`. NOT an autocomplete popup: the seam answers with
-/// locations, so the question it asks is "what could go here, and where is
-/// each of those DEFINED" — one `+Search` row per candidate, pointing at its
-/// declaration, the same rows `gr` emits and the same `n`/`N` that step them.
-/// Nothing is inserted; picking is looking at a row.
-///
-/// A dot has two meanings and both come off the public analyser:
-/// `foo.` a field access — resolve the left side's type, list its members.
-/// `.` an enum literal — resolve the type EXPECTED at this position (a
-/// switch's condition, a call's parameter, a variable's annotation,
-/// an initialiser's field) and list that type's members.
-/// The second exists only because `completionSource` made the tree parse.
-fn completion(
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
- /// req.root: what the rows' paths are written relative to (lsp.rel)
- base: []const u8,
- /// the buffer as the USER has it, before completionSource repaired it. The
- /// rows are read out of THIS one; the handle's tree is the repaired copy
- /// and its text is not what is on screen.
- orig: []const u8,
- out: *std.Io.Writer,
- tr: *Trace,
-) !void {
- const tree = &handle.tree;
- // lookahead FALSE, the way ZLS's own completion asks: the cursor sits at
- // the END of what was typed and there is nothing to its right to read.
- const ctx = try Analyser.getPositionContext(arena, tree, off, false);
- tr.note("position context: .{s}", .{@tagName(ctx)});
- // naming a field INSIDE an initialiser (`.{ .`, `S{ .`) rather than
- // writing a whole value — a different question with a different answer,
- // and the only one the ancestors cannot state for themselves
- var in_init = false;
- const found: ?Analyser.Type = switch (ctx) {
- .field_access => |loc| try analyser.getFieldAccessType(handle, off, loc),
- .enum_literal => blk: {
- const nodes = try zls.ast.nodesOverlappingIndex(arena, tree, off);
- if (nodes.len == 0) break :blk null;
- if (try analyser.resolveExpressionTypeFromAncestors(handle, nodes[0], nodes[1..])) |t| break :blk t;
- // `.{ .` and `S{ .` parse as an ARRAY init holding one enum
- // literal, and an array's ELEMENT type is not what is being named
- // there — the user is naming a FIELD, so the type wanted is the
- // initialiser's own.
- var buf: [2]Ast.Node.Index = undefined;
- if (nodes.len > 1 and tree.fullArrayInit(&buf, nodes[1]) != null) {
- in_init = true;
- break :blk try analyser.resolveExpressionType(handle, nodes[1], nodes[2..]);
- }
- break :blk null;
- },
- // a dot in a comment or a string is a dot in prose
- else => null,
- };
- var ty = found orelse {
- tr.note("STOP: nothing expected here that this backend can name", .{});
- return;
- };
- // the same peel `gy` does: what can go in a `?E`, an `E!T` or a `*E` slot
- // is what can go in an `E` one
- while (true) {
- ty = try analyser.resolveUnwrapErrorUnionType(ty, .payload) orelse
- try analyser.resolveDerefType(ty) orelse
- try analyser.resolveOptionalUnwrap(ty) orelse break;
- }
- // `instance_access` is the collector's one knob and it
- // means different things per container kind: it is what hides an enum's
- // members and what reveals a struct's fields. So: a field access asks
- // whichever side the left-hand expression already was; a field named
- // inside an initialiser asks the instance side, which is where fields
- // live; and everywhere else — a switch arm, an argument, `= .` — asks the
- // type side, which is where an enum's members are. A union is a struct
- // whose fields are also its tags, so it wants the instance side wherever
- // it appears.
- const value_pos = ctx == .enum_literal and !in_init;
- const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows);
- var decl_count: usize = 0;
- try collectContainerDecls(analyser, ty, handle, switch (ctx) {
- .field_access => !ty.is_type_val,
- else => in_init or ty.isUnionType(),
- }, decls, &decl_count);
- tr.note("{d} candidate(s) in scope", .{decl_count});
- // What completionSource spliced in at `off`, which the ROWS have to take
- // back out. The tree being read is the REPAIRED copy, so for a candidate
- // declared in this same file two things are wrong with the naive row: its
- // line text is the repaired line, so the user reads `= ._p;` back out of a
- // file that says no such thing; and every column on the cursor's line to
- // the right of the dot is `pad` too far right, so a look on the row
- // selects `", "` where it should select `qq`. Line numbers need no
- // correction at all — no spelling contains a newline — and a candidate in
- // ANOTHER file is untouched by any of this.
- const pad = tree.source.len - orig.len;
- const dot = lsp.lineCol(orig, off);
- var n: usize = 0;
- for (decls[0..decl_count]) |d| {
- if (n >= max_rows) return;
- // In a value position the answer is the container's MEMBERS plus its
- // DECL LITERALS (`.empty`, `.init`) — never a method, an unrelated
- // const or a type ALIAS, none of which can go after that dot, and
- // offering one is worse than offering nothing. So every non-member
- // decl is resolved, called, and kept only if what comes back IS this
- // container. `typeOf` on both sides is what excludes the alias: a
- // `pub const Alias = E` resolves to a TYPE VALUE, whose type is
- // `type` and not the container. (ZLS's own rule, and its own reason.)
- const member = d.decl == .ast_node and d.handle.tree.nodeTag(d.decl.ast_node).isContainerField();
- if (value_pos and !member) {
- var lit = try d.resolveType(analyser) orelse continue;
- lit = try analyser.resolveReturnType(lit) orelse lit;
- lit = lit.resolveDeclLiteralResultType();
- if (!(try lit.typeOf(analyser)).eql(try ty.typeOf(analyser))) continue;
- }
- // The WORD, off the decl's own name token. Not the definition token
- // below it: `definitionToken` resolves an ALIAS through to what it
- // names, so for `pub const base64 = @import("base64.zig")` it lands on
- // base64.zig's first token and the "name" would come out as that file's
- // doc comment. What goes after the dot is what the container calls it.
- const name_tok = d.nameToken();
- if (name_tok >= d.handle.tree.tokens.len) continue;
- const name = offsets.tokenToSlice(&d.handle.tree, name_tok);
-
- const th = d.definitionToken(analyser, true) catch continue;
- const dtree = &th.handle.tree;
- if (th.token >= dtree.tokens.len) continue;
- var r = offsets.tokenToRange(dtree, th.token, enc);
- const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch continue);
- // The name, then its declaration line: "what goes here" before "where
- // does it come from", which is the order the question was asked in —
- // every other kind here answers a WHERE, this one answers a WHAT and
- // the location is the evidence. Two spaces rather than a padded column,
- // because the `path:LINE:COL-END` in front of it is already ragged and
- // there is nothing to align to.
- const text = try std.fmt.allocPrint(arena, "{s} {s}", .{
- name,
- std.mem.trim(u8, lineAt(if (th.handle == handle) orig else dtree.source, r.start.line), " \t\r\n"),
- });
- if (th.handle == handle and pad != 0 and r.start.line == dot.line and
- @as(usize, r.start.character) >= dot.col + pad)
- {
- r.start.character -= @intCast(pad);
- if (r.end.line == r.start.line) r.end.character -= @intCast(pad);
- }
- try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text);
- n += 1;
- }
-}
-
-// ---------------------------------------------------------------- hover
-
-/// `hover.hover` is already Server-free, so this is a call and a reformat: the
-/// `+Hover` buffer holds prose, not locations, so the markdown comes through
-/// 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(
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
- out: *std.Io.Writer,
-) !void {
- const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return;
- const text = switch (h.contents) {
- .markup_content => |m| m.value,
- else => return,
- };
- var it = std.mem.splitScalar(u8, text, '\n');
- while (it.next()) |ln| {
- if (std.mem.startsWith(u8, ln, "```")) continue;
- try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")});
- }
-}
-
-// ---------------------------------------------------------------- symbols
-
-/// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding
-/// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest
-/// thing in this file: a parse and a walk.
-fn documentSymbols(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- path: []const u8,
- tree: *const Ast,
- out: *std.Io.Writer,
-) !void {
- const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc);
- var n: usize = 0;
- try emitSymbols(gpa, arena, path, syms, "", &n, out);
-}
-
-/// Depth-first so the rows read in source order, with the parent's name as a
-/// qualifier — `Pardes.update` rather than a bare `update`,
-/// because a flat list of method names is not navigable.
-fn emitSymbols(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- path: []const u8,
- syms: []const types.DocumentSymbol,
- prefix: []const u8,
- n: *usize,
- out: *std.Io.Writer,
-) !void {
- for (syms) |s| {
- if (n.* >= max_rows) return;
- n.* += 1;
- const name = if (prefix.len == 0)
- s.name
- else
- try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name });
- const text = if (s.detail) |d|
- try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d })
- else
- name;
- try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text);
- if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out);
- }
-}
-
-/// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered
-/// by substring. ZLS's own workspace symbols would only see files already in
-/// the DocumentStore (which is one), so this walks the tree itself — and
-/// because `getDocumentSymbols` needs nothing but a tree, it never has to open
-/// a store or resolve an import to do it.
-fn workspaceSymbols(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- io: std.Io,
- req: lsp.Req,
- out: *std.Io.Writer,
-) !void {
- if (req.arg.len == 0) return;
- const files = try collectZigFiles(arena, io, req.root);
- var n: usize = 0;
- for (files) |path| {
- if (n >= max_rows) return;
- const src = readFileZ(arena, io, path) catch continue;
- var tree: Ast = Ast.parse(arena, src, .zig) catch continue;
- defer tree.deinit(arena);
- const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue;
- try filterSymbols(gpa, arena, lsp.rel(req.root, path), syms, "", req.arg, &n, out);
- }
-}
-
-fn filterSymbols(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- path: []const u8,
- syms: []const types.DocumentSymbol,
- prefix: []const u8,
- needle: []const u8,
- n: *usize,
- out: *std.Io.Writer,
-) !void {
- for (syms) |s| {
- if (n.* >= max_rows) return;
- const name = if (prefix.len == 0)
- s.name
- else
- try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name });
- if (containsIgnoreCase(s.name, needle)) {
- n.* += 1;
- try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name);
- }
- if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out);
- }
-}
-
-fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool {
- if (needle.len == 0) return true;
- if (needle.len > hay.len) return false;
- var i: usize = 0;
- outer: while (i + needle.len <= hay.len) : (i += 1) {
- for (needle, 0..) |c, j|
- if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer;
- return true;
- }
- return false;
-}
-
-// ------------------------------------------------------------- references
-
-/// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its
-/// `referencesHandler` takes a `*Server`, so this is the same algorithm from
-/// the outside: find the decl under the cursor, then offer every identifier
-/// token in the file that spells the same name back to the analyser and keep
-/// the ones that resolve to the same decl. Resolving rather than matching is
-/// what makes a shadowed local not a reference to the outer one.
-///
-/// LIMIT: this file only. A workspace-wide answer means loading every project
-/// file into the store and running the analyser over each — seconds, not
-/// milliseconds, on every keypress, and the store's own workspace iteration
-/// has the same restriction (it can only see handles that were loaded).
-///
-/// `new_name` non-null makes it a rename EDIT: the same resolved tokens become
-/// half-open byte ranges. The core owns the replacement text and applies every
-/// range in one undo transaction after checking the source revision.
-fn references(
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
- new_name: ?[]const u8,
- /// req.root: what the rows' paths are written relative to (lsp.rel)
- base: []const u8,
- out: *std.Io.Writer,
-) !void {
- const tree = &handle.tree;
- const target = try declAt(arena, analyser, handle, off) orelse return;
- // The decl's name token indexes ITS OWN file, which after an alias resolve
- // is not necessarily this one — and on a half-typed file it is not
- // necessarily an identifier either, which `identifierTokenToNameSlice`
- // asserts. Both of those are crashes on a keystroke, so both are checked.
- const decl_tree = &target.handle.tree;
- const name_tok = target.nameToken();
- if (name_tok >= decl_tree.tokens.len) return;
- if (decl_tree.tokenTag(name_tok) != .identifier) return;
- const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok);
- if (want.len == 0) return;
-
- // Rename consumes exact byte ranges. Reference rows need the source line
- // and displayed path; avoid building either for the mutating response.
- const lines: ?Lines = if (new_name == null) try .build(arena, tree.source) else null;
- const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else "";
- var n: usize = 0;
- for (0..tree.tokens.len) |i| {
- if (new_name == null and n >= max_rows) return;
- const tok: Ast.TokenIndex = @intCast(i);
- if (tree.tokenTag(tok) != .identifier) continue;
- if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue;
- const at = tree.tokenStart(tok);
- const d = (declAt(arena, analyser, handle, at) catch |err| {
- if (new_name != null) return err;
- continue;
- }) orelse continue;
- if (!d.eql(target)) continue;
- if (n >= max_rows) return error.TooManyEdits;
- n += 1;
- if (new_name != null) {
- try lsp.edit(out, at, at + want.len);
- } else {
- const r = offsets.tokenToRange(tree, tok, enc);
- try lsp.sourceSpanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line));
- }
- }
-}
-
-/// The decl under a byte offset, whatever the surrounding syntax is. Shared by
-/// references and by rename, which is references with a label on it.
-fn declAt(
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
-) !?Analyser.DeclWithHandle {
- const tree = &handle.tree;
- const ctx = try Analyser.getPositionContext(arena, tree, off, true);
- const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null;
- const name = offsets.locToSlice(tree.source, name_loc);
- const d = switch (ctx) {
- .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off),
- .field_access => |loc| blk: {
- const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null;
- break :blk if (found.len != 0) found[0] else null;
- },
- .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off),
- .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name),
- else => null,
- } orelse return null;
- return try analyser.resolveVarDeclAlias(d) orelse d;
-}
-
-// ------------------------------------------------------------ diagnostics
-
-/// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers
-/// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this
-/// process, which is the branch a subprocess-free backend wants, so that is
-/// what is inlined here (it is ten lines and needs a `*Server` only for the
-/// config it reads).
-///
-/// A clean file emits ONE row saying so. Silence already means "no backend" in
-/// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that
-/// checked and found nothing has to be able to say the difference.
-fn diagnostics(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- path: []const u8,
- tree: *const Ast,
- out: *std.Io.Writer,
-) !void {
- const n = try treeDiagnostics(gpa, arena, path, tree, out);
- if (n == 0) try lsp.row(out, path, 0, 0, "no diagnostics");
-}
-
-/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it
-/// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback
-/// is this backend's only branch. Caller owns the bundle.
-fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle {
- if (tree.errors.len != 0) return .empty;
- var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty;
- defer zir.deinit(gpa);
- if (!zir.hasCompileErrors()) return .empty;
- var wip: std.zig.ErrorBundle.Wip = undefined;
- try wip.init(gpa);
- defer wip.deinit();
- wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty;
- return wip.toOwnedBundle("") catch .empty;
-}
-
-/// Parse errors first (AstGen cannot run on a tree that did not parse), then
-/// the AstGen pass. Returns how many rows were emitted.
-fn treeDiagnostics(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- path: []const u8,
- tree: *const Ast,
- out: *std.Io.Writer,
-) !usize {
- var n: usize = 0;
- if (tree.errors.len != 0) {
- const lines: Lines = try .build(arena, tree.source);
- for (tree.errors) |e| {
- if (n >= max_rows) return n;
- var count_buf: [256]u8 = undefined;
- var counting: std.Io.Writer.Discarding = .init(&count_buf);
- tree.renderError(e, &counting.writer) catch continue;
- const size = std.math.cast(usize, counting.fullCount()) orelse continue;
- const rendered = try arena.alloc(u8, size);
- var w: std.Io.Writer = .fixed(rendered);
- tree.renderError(e, &w) catch continue;
- const at = tree.tokenStart(e.token);
- const lc = lsp.lineCol(tree.source, at);
- n += 1;
- try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{
- w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"),
- }));
- }
- return n;
- }
-
- var bundle = try astCheck(gpa, path, tree);
- defer bundle.deinit(gpa);
- if (bundle.errorMessageCount() == 0) return n;
- for (bundle.getMessages()) |m| {
- if (n >= max_rows) return n;
- const em = bundle.getErrorMessage(m);
- if (em.src_loc == .none) continue;
- const sl = bundle.getSourceLocation(em.src_loc);
- n += 1;
- try lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{
- bundle.nullTerminatedString(em.msg),
- }));
- }
- return n;
-}
-
-/// `SPC D`: the same check over every `.zig` under the pane's directory. No
-/// analyser and no store — AstGen is a per-file pass — so this is bounded by
-/// how fast Zig can parse the tree, which for pardes's own `src/` is tens of
-/// milliseconds.
-fn workspaceDiagnostics(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- io: std.Io,
- req: lsp.Req,
- out: *std.Io.Writer,
-) !void {
- const files = try collectZigFiles(arena, io, req.root);
- var total: usize = 0;
- for (files) |path| {
- const src = readFileZ(arena, io, path) catch continue;
- var tree: Ast = Ast.parse(arena, src, .zig) catch continue;
- defer tree.deinit(arena);
- total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out);
- if (total >= max_rows) return;
- }
- if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len}));
-}
-
-// ----------------------------------------------------------- code actions
-
-/// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants
-/// an arena, an analyser, a handle and an ErrorBundle — so the only missing
-/// piece is the bundle, which `astCheck` above produces without a compiler.
-///
-/// It lists what could be done, and does none of it: the seam hands back rows,
-/// not edits, so there is no channel through which a backend could apply a
-/// fix. `+Lsp` is prose, so these are lines rather than locations.
-fn codeActions(
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- analyser: *Analyser,
- handle: *DocumentStore.Handle,
- off: usize,
- out: *std.Io.Writer,
-) !void {
- const tree = &handle.tree;
- if (tree.errors.len != 0) {
- try out.print("no code actions: file does not parse\n", .{});
- return;
- }
- var bundle = try astCheck(gpa, "", tree);
- defer bundle.deinit(gpa);
-
- // The external builder owns growable collections internally. Confine all
- // of them and their payloads to one preallocated query-local region.
- const action_storage = try arena.alloc(u8, max_code_action_bytes);
- var action_fba: std.heap.FixedBufferAllocator = .init(action_storage);
- var builder: zls.code_actions.Builder = .{
- .arena = action_fba.allocator(),
- .analyser = analyser,
- .handle = handle,
- .offset_encoding = enc,
- .only_kinds = null,
- };
- builder.generateCodeAction(bundle) catch {};
- const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc);
- builder.generateCodeActionsInRange(at) catch {};
-
- for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| {
- try out.print("{s}\n", .{a.title});
- }
- if (builder.actions.items.len == 0) try out.print("no code actions\n", .{});
-}
-
-// ---------------------------------------------------------------- format
-
-/// `=`. The response is `@put` edit records (see lsp.put): one span covering
-/// everything `zig fmt` would change, which the core substitutes as a single
-/// undo step — so `=` FORMATS, exactly like helix. The two answers that are
-/// not edits stay prose rows in `+Lsp`: a file that does not parse cannot be
-/// formatted, and a file already formatted has nothing to apply.
-fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
- const path = lsp.rel(req.root, req.path);
- var tree: Ast = try .parse(arena, req.source, .zig);
- defer tree.deinit(arena);
- if (tree.errors.len != 0) {
- try lsp.row(out, path, 0, 0, "cannot format: file does not parse");
- return;
- }
- var count_buf: [4096]u8 = undefined;
- var counting: std.Io.Writer.Discarding = .init(&count_buf);
- try tree.render(arena, &counting.writer, .{});
- const size = std.math.cast(usize, counting.fullCount()) orelse return;
- const render_buf = try arena.alloc(u8, size);
- var w: std.Io.Writer = .fixed(render_buf);
- try tree.render(arena, &w, .{});
- const formatted = w.buffered();
- if (std.mem.eql(u8, formatted, req.source)) {
- try lsp.row(out, path, 0, 0, "already formatted");
- return;
- }
- // one record, spanning only what changed: the common prefix and suffix
- // stay untouched, which is also what keeps the cursor mapping tight
- var lo: usize = 0;
- const min_len = @min(req.source.len, formatted.len);
- while (lo < min_len and req.source[lo] == formatted[lo]) lo += 1;
- var src_hi = req.source.len;
- var fmt_hi = formatted.len;
- while (src_hi > lo and fmt_hi > lo and req.source[src_hi - 1] == formatted[fmt_hi - 1]) {
- src_hi -= 1;
- fmt_hi -= 1;
- }
- try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]);
-}
-
-// ------------------------------------------------------------------ files
-
-/// Every `.zig` under `root`, skipping the directories a source walk has no
-/// business in. Capped, because this runs on a keypress.
-fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]const []const u8 {
- const files = try arena.alloc([]const u8, max_files);
- var len: usize = 0;
- if (root.len == 0) return files[0..0];
- var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return files[0..0];
- defer dir.close(io);
- var w = dir.walkSelectively(arena) catch return files[0..0];
- defer w.deinit();
- while (len < files.len) {
- const e = (w.next(io) catch break) orelse break;
- switch (e.kind) {
- .directory => {
- if (e.basename.len != 0 and e.basename[0] == '.') continue;
- if (std.mem.eql(u8, e.basename, "zig-out")) continue;
- if (std.mem.eql(u8, e.basename, "zig-cache")) continue;
- w.enter(io, e) catch {};
- },
- .file => {
- if (!std.mem.endsWith(u8, e.basename, ".zig")) continue;
- files[len] = std.fs.path.join(arena, &.{ root, e.path }) catch continue;
- len += 1;
- },
- else => {},
- }
- }
- return files[0..len];
-}
-
-fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 {
- return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0);
-}
-
-test "LSP ZLS format propagates output failure and preserves successful encoding" {
- const gpa = std.testing.allocator;
- const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 };
- const expected = "@put 11 12 %20=%20\n";
- var buffer: [128]u8 = undefined;
- for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| {
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- var out: std.Io.Writer = .fixed(buffer[0..capacity]);
- const result = query(gpa, arena.allocator(), req, &out);
- if (capacity < expected.len) {
- try std.testing.expectError(error.WriteFailed, result);
- } else {
- try result;
- try std.testing.expectEqualStrings(expected, out.buffered());
- }
- }
-}
-
-test "LSP ZLS rename refuses a partial edit set beyond its row budget" {
- const gpa = std.testing.allocator;
- for ([_]usize{ max_rows - 1, max_rows }) |references_count| {
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- var source: std.Io.Writer.Allocating = .init(gpa);
- defer source.deinit();
- try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n");
- for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n");
- try source.writer.writeAll("}\n");
- const text = try gpa.dupeZ(u8, source.written());
- defer gpa.free(text);
- var out: std.Io.Writer.Allocating = .init(gpa);
- defer out.deinit();
- const result = query(gpa, arena.allocator(), .{
- .kind = .rename,
- .path = "/rename.zig",
- .source = text,
- .offset = 6,
- .arg = "renamed",
- }, &out.writer);
- if (references_count < max_rows) {
- try result;
- try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit "));
- } else {
- try std.testing.expectError(error.TooManyEdits, result);
- try std.testing.expectEqual(@as(usize, 0), out.written().len);
- }
- }
-}
-
-test "LSP source snippets retain native definition and reference indentation" {
- const source = "const Outer = struct {\n" ++
- "\t const value: u32 = 1;\n" ++
- "\t fn use() void {\n" ++
- "\t\t_ = value;\n" ++
- "\t }\n" ++
- "};\n";
- const gpa = std.testing.allocator;
- for ([_]lsp.Kind{ .definition, .declaration, .references }) |kind| {
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- var out: std.Io.Writer.Allocating = .init(gpa);
- defer out.deinit();
- try query(gpa, arena.allocator(), .{
- .kind = kind,
- .path = "/source-snippet.zig",
- .root = "/",
- .source = source,
- .offset = @intCast(std.mem.lastIndexOf(u8, source, "value").?),
- }, &out.writer);
- const definition = "source-snippet.zig:2:10-14 \t const value: u32 = 1;\n";
- try std.testing.expectEqualStrings(if (kind == .references)
- definition ++ "source-snippet.zig:4:7-11 \t\t_ = value;\n"
- else
- definition, out.written());
- }
-}
diff --git a/src/main.zig b/src/main.zig
index 17d0fb5f..0e2ba954 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -535,7 +535,6 @@ test {
_ = @import("host_io.zig").Lsp;
_ = @import("lsp/lsp_client.zig");
_ = @import("lsp/lsp.zig");
- if (comptime @import("pardes_config").zls_backend) _ = @import("lsp/lsp_zls.zig");
if (comptime pardes.platform == .tty) {
_ = @import("tty/tty.zig");
_ = @import("tty/panel_compositor.zig");
diff --git a/src/pardes.zig b/src/pardes.zig
index e3db97d9..a63568db 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -751,13 +751,6 @@ test "Collapse builtin keeps content and restores the pane from its default tag"
p.sync();
try std.testing.expect(!pane.collapsed);
try std.testing.expectEqual(height, p.rects[0].h);
-
- // Unedited defaults from the preceding version gain the new command.
- if (pane.tag.own) |own| p.gpa.free(own);
- pane.tag.own = null;
- tagline.restoreTailAt(p, pane, tagline.pre_collapse_file_pane_tail, "", .file);
- try std.testing.expect(pane.tag.own == null);
- try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane));
}
test "LocationsConfig command reports partial updates and survives restore" {
@@ -985,129 +978,6 @@ test "compact tag commands stay beside their own prefix across layout changes" {
try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above));
}
-test "legacy default tag tails upgrade while custom tails remain owned" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{});
- defer p.deinit();
-
- const terminal = p.panes[0].?;
- const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
- try tagline.tagPrefix(p, terminal),
- tagline.legacy_pane_tail,
- });
- tagline.restoreTail(p, terminal, terminal_old);
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
-
- const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
- try tagline.tagPrefix(p, terminal),
- });
- tagline.restoreTail(p, terminal, terminal_previous);
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
-
- const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
- try tagline.tagPrefix(p, terminal),
- tagline.prev_terminal_pane_tail,
- });
- tagline.restoreTail(p, terminal, terminal_prefilter);
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
-
- for ([_][]const u8{ tagline.pre_mode_terminal_pane_tail, tagline.pre_tty_first_terminal_pane_tail, tagline.pre_collapse_terminal_pane_tail }) |old_tail| {
- const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try tagline.tagPrefix(p, terminal), old_tail });
- tagline.restoreTail(p, terminal, old);
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
- }
- const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try tagline.tagPrefix(p, terminal), tagline.pre_mode_terminal_pane_tail });
- tagline.restoreTail(p, terminal, custom_mode);
- try std.testing.expect(terminal.tag.own != null);
- try std.testing.expectEqualStrings(tagline.pre_mode_terminal_pane_tail ++ " Keep", tagline.curTail(terminal));
-
- if (terminal.tag.own) |own| p.gpa.free(own);
- terminal.tag.own = null;
- const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
- try tagline.tagPrefix(p, terminal),
- });
- tagline.restoreTail(p, terminal, terminal_custom);
- try std.testing.expect(terminal.tag.own != null);
- try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal));
-
- if (terminal.tag.own) |own| p.gpa.free(own);
- terminal.tag.own = null;
- tagline.restoreDumpTail(p, terminal, .{
- .kind = .terminal,
- .tag = "TTY /historical/cwd Keep Del",
- .body = "",
- .terminal = .{ .cwd = "/historical/cwd" },
- });
- try std.testing.expect(terminal.tag.own != null);
- try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal));
-
- if (terminal.tag.own) |own| p.gpa.free(own);
- terminal.tag.own = null;
- tagline.restoreDumpTail(p, terminal, .{
- .kind = .terminal,
- .tag = "TTY /historical/cwd New Del",
- .body = "",
- .terminal = .{ .cwd = "/historical/cwd" },
- });
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
-
- // This happens to be the historical FILE default, but on a terminal it is
- // user-owned text and must not be swallowed by migration.
- if (terminal.tag.own) |own| p.gpa.free(own);
- terminal.tag.own = null;
- const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{
- try tagline.tagPrefix(p, terminal),
- });
- tagline.restoreTail(p, terminal, terminal_fileish);
- try std.testing.expect(terminal.tag.own != null);
- try std.testing.expectEqualStrings(" Save Del", tagline.curTail(terminal));
-
- // A savable file has a distinct old default. Save remains first after the
- // migration so the tag's established `:w<Tab>` route is unchanged.
- const file = try p.setTestFile("");
- const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
- try tagline.tagPrefix(p, file),
- tagline.legacy_file_pane_tail,
- });
- tagline.restoreTail(p, file, file_old);
- try std.testing.expect(file.tag.own == null);
- try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(file));
-
- const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
- try tagline.tagPrefix(p, file),
- });
- tagline.restoreTail(p, file, file_custom);
- try std.testing.expect(file.tag.own != null);
- try std.testing.expectEqualStrings(" Save Mine Del", tagline.curTail(file));
-
- // Conversely the generic pane's current default is a custom edit on a
- // savable file. Legacy recognition is scoped to the source pane class.
- if (file.tag.own) |own| p.gpa.free(own);
- file.tag.own = null;
- const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
- try tagline.tagPrefix(p, file),
- });
- tagline.restoreTail(p, file, file_generic);
- try std.testing.expect(file.tag.own != null);
- try std.testing.expectEqualStrings(" New Del", tagline.curTail(file));
-
- const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{});
- try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(image_doc));
- tagline.restoreDumpTail(p, image_doc, .{
- .kind = .image,
- .tag = "img /tmp/legacy image.ppm Keep Del",
- .body = "",
- .image = .{ .path = "/tmp/legacy image.ppm" },
- });
- try std.testing.expect(image_doc.tag.own != null);
- try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(image_doc));
-}
-
test "Joincol folds the active column into its right neighbor, keeping its panes" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -1434,14 +1304,6 @@ test "pane defaults keep creation of scratch panes in column tags" {
while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New"));
}
try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0));
- const terminal = p.panes[0].?;
- tagline.restoreTailAt(p, terminal, tagline.pre_column_terminal_pane_tail, "", .terminal);
- try std.testing.expect(terminal.tag.own == null);
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal));
- const file = try p.setTestFile("unchanged\n");
- tagline.restoreTailAt(p, file, tagline.pre_column_file_pane_tail, "", .file);
- try std.testing.expect(file.tag.own == null);
- try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file));
}
test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" {
@@ -2640,27 +2502,8 @@ test "Save reaches every tagline with text behind it and no other" {
out.file.?.output = .{ .from = .search };
try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out));
- // A dump written before Save reached output buffers carries the generic
- // tail. That is a DEFAULT and not something typed, so it upgrades.
- const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
- try tagline.tagPrefix(p, out),
- tagline.pane_tail,
- });
- tagline.restoreTail(p, out, dumped);
- try std.testing.expect(out.tag.own == null);
- try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out));
-
- // A scratch is an output buffer that has worn the FILE tails all along,
- // so its own older default upgrades from that family too.
+ // A scratch is an output buffer that wears the FILE tails.
out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } };
- if (out.tag.own) |own| p.gpa.free(own);
- out.tag.own = null;
- const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
- try tagline.tagPrefix(p, out),
- tagline.prev_file_pane_tail,
- });
- tagline.restoreTail(p, out, scratch_dumped);
- try std.testing.expect(out.tag.own == null);
try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(out));
// An image's bytes on disk already are what they are: nothing of the
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 8f842cde..5646a92f 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -57,24 +57,6 @@ pub const RasterPolicy = struct {
match_viewport: bool,
};
-pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 {
- if (std.mem.startsWith(u8, saved_tag, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) {
- const start = path.len + 2;
- const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null;
- var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/');
- _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
- _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
- if (parts.next() != null) return null;
- return saved_tag[0 .. end + 1];
- }
- if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null;
- const marker = " PdfSections ";
- const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null;
- const path_at = marker_at + marker.len;
- if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null;
- return saved_tag[0 .. path_at + path.len];
-}
-
pub const TintKey = if (enabled) struct {
mode: TintMode,
colors: pdf.TintColors,
diff --git a/src/tagline.zig b/src/tagline.zig
index df093925..1923871a 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -23,43 +23,11 @@ pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
-/// The mark a tag carried after an unsaved file's name before the grip
-/// showed it; a dump from then still has it in its saved tags.
-const dirty_marker = " *";
-
/// A file with changes it has not saved.
pub fn dirty(pane: *const panes.Pane) bool {
const f = pane.file orelse return false;
return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone);
}
-pub const legacy_pane_tail = " Del";
-pub const legacy_file_pane_tail = " Save Del";
-// The defaults from the release before Newtty joined every tagline. Recognized
-// so a dump written then upgrades instead of keeping the old word as a tail.
-const prev_pane_tail = " New Del";
-pub const prev_file_pane_tail = " Save New Del";
-const legacy_terminal_pane_tail = " New Del Filter";
-// ...and the terminal default from the release before Save reached it, when a
-// scrollback was not yet something you could write to a path.
-pub const prev_terminal_pane_tail = " New Newtty Del Filter";
-const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter";
-const pre_column_pane_tail = " New Newtty Del";
-pub const pre_column_file_pane_tail = " Save New Newtty Del";
-const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint";
-pub const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter";
-const pre_collapse_pane_tail = " Tty Del";
-pub const pre_collapse_file_pane_tail = " Save Tty Del";
-const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint";
-pub const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter";
-pub const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse";
-pub const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse";
-// ...and the defaults from before the closing word moved to the end of every
-// tagline, where a click cannot land on it by overshooting the one before.
-const pre_close_last_pane_tail = " Tty Del Collapse";
-const pre_close_last_file_pane_tail = " Save Tty Del Collapse";
-const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse";
-const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse";
-
/// What a column's tag says until someone edits it.
pub const column_tag_default = "New Tty Find Grep Joincol Delcol";
@@ -420,106 +388,11 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void {
t.normal.clear();
}
+/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on
+/// writes it, and an older one's saved tag is not read.
pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
- if (src.tag_tail) |tail| {
- pane.tag.own = p.gpa.dupe(u8, tail) catch return;
- return;
- }
- const tail_class = tailClass(pane);
- if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) {
- if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix|
- return restoreTailAt(p, pane, src.tag, prefix, .generic);
- }
- if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) {
- const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return;
- if (std.mem.startsWith(u8, src.tag, legacy))
- return restoreTailAt(p, pane, src.tag, legacy, .terminal);
- }
- const current = tagPrefix(p, pane) catch return;
- if (std.mem.startsWith(u8, src.tag, current))
- return restoreTailAt(p, pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class);
- if (src.kind != .image) return;
- const saved = src.image.?;
- if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy|
- return restoreTailAt(p, pane, src.tag, legacy, .generic);
- if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy|
- restoreTailAt(p, pane, src.tag, legacy, .generic);
-}
-
-pub fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
- const pfx = tagPrefix(p, pane) catch return;
- const class = tailClass(pane);
- restoreTailAt(p, pane, saved_tag, savedPrefix(saved_tag, pfx, class), class);
-}
-
-const TailClass = enum { generic, file, output, terminal };
-
-fn tailClass(pane: *const Pane) TailClass {
- if (pane.file) |f| return if (f.output == null) .file else .output;
- if (pane.isTerminal()) return .terminal;
- return .generic;
-}
-
-fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 {
- if (class == .generic or class == .terminal) return live;
- if (!std.mem.startsWith(u8, saved_tag, live)) return live;
- const marked_len = live.len + dirty_marker.len;
- if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker))
- return saved_tag[0..marked_len];
- return live;
-}
-
-pub fn restoreTailAt(
- p: *Pardes,
- pane: *Pane,
- saved_tag: []const u8,
- pfx: []const u8,
- class: TailClass,
-) void {
- if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
- const rest = saved_tag[pfx.len..];
- const said = std.mem.trimStart(u8, rest, " ");
- const defaults: []const []const u8 = switch (class) {
- .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
- .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
- .output => &.{
- locations_pane_tail,
- file_pane_tail,
- pre_close_last_file_pane_tail,
- pre_close_last_pane_tail,
- prev_file_pane_tail,
- legacy_file_pane_tail,
- pane_tail,
- prev_pane_tail,
- legacy_pane_tail,
- pre_column_file_pane_tail,
- pre_column_pane_tail,
- pre_collapse_file_pane_tail,
- pre_collapse_pane_tail,
- },
- .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
- };
- // A terminal's saved tag carries what was live about it -- the REPL id
- // after its directory, the shell in its Tty word -- which is neither
- // dumped nor true of the new shell: its tail is still the default.
- var plain = said;
- var plain_buf: [256]u8 = undefined;
- if (class == .terminal) {
- const first = plain[0 .. std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len];
- if (std.mem.lastIndexOfScalar(u8, first, '-')) |dash| if (dash + 2 == first.len and first[dash + 1] >= 'a' and first[dash + 1] <= 'z') {
- for (@import("grammar_manifest.zig").all) |g| if (std.mem.eql(u8, g.name, first[0..dash])) {
- plain = std.mem.trimStart(u8, plain[first.len..], " ");
- break;
- };
- };
- if (std.mem.startsWith(u8, plain, "Tty+")) {
- const word_end = std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len;
- plain = std.fmt.bufPrint(&plain_buf, "Tty{s}", .{plain[word_end..]}) catch plain;
- }
- }
- for (defaults) |default|
- if (std.mem.eql(u8, plain, std.mem.trimStart(u8, default, " "))) return;
- pane.tag.own = p.gpa.dupe(u8, rest) catch return;
+ const tail = src.tag_tail orelse return;
+ pane.tag.own = p.gpa.dupe(u8, tail) catch return;
}
/// The span a header's selection covers on line `row` of its text, in
diff --git a/src/terminal.zig b/src/terminal.zig
index c99a92a6..6bf46bef 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -509,13 +509,7 @@ pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane {
pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
return pane;
}
- // An older dump's raw output tail cannot come back live: cut off mid
- // redraw, or written at sizes the pane no longer has, it replays as
- // garbage. Its rendered text can.
- const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0;
- const bytes = if (legacy_text)
- try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n")
- else if (terminal.stream_b64.len > 0)
+ const bytes = if (terminal.stream_b64.len > 0)
try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
else
&.{};
@@ -759,31 +753,6 @@ test "a restored terminal keeps its history above a marker and gets a shell wher
try std.testing.expect(old_at < mark_at);
}
-test "an older dump comes back live from its rendered text, not its raw output tail" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- // A tail cut mid-redraw: relative moves from a cursor it never set.
- const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore");
- defer gpa.free(tail);
- const restored = try restore(p, .{
- .kind = .terminal,
- .tag = "",
- .body = "",
- .cols = 40,
- .rows = 12,
- .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail },
- }, true);
- defer gpa.destroy(restored);
- defer deinitEmulator(restored, gpa);
- defer deinitPendingCommand(restored);
- const text = try screenTextAlloc(restored, gpa);
- defer gpa.free(text);
- try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null);
-}
-
/// Record and parse one live pty read, invalidate its motion surface, and
/// follow it only when the body (possibly parked under a tag edit) is raw.
pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 3441bc29..ef7f2761 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1791,24 +1791,11 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, path, err);
- if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
- if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
- .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
- .pdf => {},
- };
- };
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
+ host_io.writeFile(s.core, &s.watches, pane, path, bytes);
}
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
- const s = of(ctx);
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
- filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
- s.core.setLastDump(path);
+ host_io.writeDump(of(ctx).core, bytes);
}
fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void {
@@ -1825,14 +1812,7 @@ const Shell = struct {
fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
const s = of(ctx);
- const config_dir = s.core.opts.config_dir orelse return;
- const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
- s.core.reportError(pane, "dump themes", err);
- return;
- };
- defer s.gpa.free(out_dir);
- var mbuf: [256]u8 = undefined;
- s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
+ host_io.dumpThemes(s.core, s.io, s.gpa, pane);
}
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {