summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig91
-rw-r--r--mupdf.zig36
-rw-r--r--src/allocators.zig113
-rw-r--r--src/builtins.zig154
-rw-r--r--src/config.zig2
-rw-r--r--src/file_pane.zig65
-rw-r--r--src/grammar_manifest.zig44
-rw-r--r--src/gui/fonts.zig16
-rw-r--r--src/gui/gui.zig475
-rw-r--r--src/look.zig191
-rw-r--r--src/lsp/lsp_zls.zig227
-rw-r--r--src/macos.zig179
-rw-r--r--src/main.zig1
-rw-r--r--src/modal.zig252
-rw-r--r--src/output_pane.zig227
-rw-r--r--src/pardes.zig1504
-rw-r--r--src/pdf.zig388
-rw-r--r--src/pdf_bridge.c112
-rw-r--r--src/pdf_bridge.h21
-rw-r--r--src/syntax.zig198
-rw-r--r--src/term_pane.zig55
-rw-r--r--src/tty/tty.zig183
-rw-r--r--src/web.zig165
-rw-r--r--test/e2e_harness.zig50
-rw-r--r--test/hxdiff.zig163
-rw-r--r--test/image_harness.zig133
-rw-r--r--test/lspbench.zig29
-rw-r--r--test/pdf_bench.zig29
-rw-r--r--test/pdf_sections_bench.zig61
-rw-r--r--test/perf.zig94
-rw-r--r--test/snapshot.zig372
-rw-r--r--tools/embed_zig_sources.zig75
-rw-r--r--tools/gen_themes.zig93
33 files changed, 3849 insertions, 1949 deletions
diff --git a/build.zig b/build.zig
index c150c9b5..e399f537 100644
--- a/build.zig
+++ b/build.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
+const grammar_manifest = @import("src/grammar_manifest.zig");
pub const Platform = enum { tty, gui, web, macos };
@@ -258,47 +259,11 @@ pub fn build(b: *std.Build) void {
root_mod.addImport("tree-sitter", tree_sitter_mod);
hx_core_mod.addImport("tree-sitter", tree_sitter_mod);
- const Grammar = struct {
- name: []const u8,
- dep: []const u8,
- src: []const u8 = "src",
- scanner: bool = false,
- query: []const u8 = "queries/highlights.scm",
- minimal: bool = false,
- };
- const grammars = [_]Grammar{
- .{ .name = "ada", .dep = "ts_ada" },
- .{ .name = "bash", .dep = "ts_bash", .scanner = true },
- .{ .name = "c", .dep = "ts_c", .minimal = true },
- .{ .name = "c_sharp", .dep = "ts_c_sharp", .scanner = true },
- .{ .name = "clojure", .dep = "ts_clojure" },
- .{ .name = "cpp", .dep = "ts_cpp", .scanner = true, .minimal = true },
- .{ .name = "css", .dep = "ts_css", .scanner = true },
- .{ .name = "elixir", .dep = "ts_elixir", .scanner = true },
- .{ .name = "erlang", .dep = "ts_erlang", .scanner = true },
- .{ .name = "fortran", .dep = "ts_fortran", .scanner = true },
- .{ .name = "go", .dep = "ts_go" },
- .{ .name = "haskell", .dep = "ts_haskell", .scanner = true },
- .{ .name = "html", .dep = "ts_html", .scanner = true },
- .{ .name = "java", .dep = "ts_java" },
- .{ .name = "javascript", .dep = "ts_javascript", .scanner = true },
- .{ .name = "json", .dep = "ts_json" },
- .{ .name = "kotlin", .dep = "ts_kotlin", .scanner = true },
- .{ .name = "ocaml", .dep = "ts_ocaml", .src = "grammars/ocaml/src", .scanner = true },
- .{ .name = "pascal", .dep = "ts_pascal" },
- .{ .name = "php", .dep = "ts_php", .src = "php/src", .scanner = true },
- .{ .name = "powershell", .dep = "ts_powershell", .scanner = true },
- .{ .name = "python", .dep = "ts_python", .scanner = true },
- .{ .name = "ruby", .dep = "ts_ruby", .scanner = true },
- .{ .name = "rust", .dep = "ts_rust", .scanner = true },
- .{ .name = "scala", .dep = "ts_scala", .scanner = true },
- .{ .name = "zig", .dep = "ts_zig", .minimal = true },
- };
const ts_queries = b.addOptions();
- inline for (grammars) |g| {
+ inline for (grammar_manifest.all) |g| {
if (tree_sitter_grammars == .full or
- (tree_sitter_grammars == .minimal and g.minimal) or
- (tree_sitter_grammars == .zig and std.mem.eql(u8, g.name, "zig")))
+ (tree_sitter_grammars == .minimal and g.tier != .full) or
+ (tree_sitter_grammars == .zig and g.tier == .zig))
{
const dep = b.dependency(g.dep, .{});
const query_path = dep.path(g.query);
@@ -530,8 +495,20 @@ pub fn build(b: *std.Build) void {
// hand it to the backend, which consults it exactly where ZLS gave up.
// Costs one build option and stays correct by construction — a dependency
// that is added or renamed above cannot forget to update it.
- var mod_names: std.ArrayList([]const u8) = .empty;
- var mod_roots: std.ArrayList([]const u8) = .empty;
+ var module_count: usize = 0;
+ {
+ var it = root_mod.import_table.iterator();
+ while (it.next()) |e| {
+ const lp = e.value_ptr.*.root_source_file orelse continue;
+ switch (lp) {
+ .src_path, .cwd_relative => module_count += 1,
+ else => continue,
+ }
+ }
+ }
+ const mod_names = b.allocator.alloc([]const u8, module_count) catch @panic("OOM");
+ const mod_roots = b.allocator.alloc([]const u8, module_count) catch @panic("OOM");
+ var module_index: usize = 0;
{
var it = root_mod.import_table.iterator();
while (it.next()) |e| {
@@ -543,12 +520,14 @@ pub fn build(b: *std.Build) void {
.cwd_relative => |cr| cr,
else => continue,
};
- mod_names.append(b.allocator, e.key_ptr.*) catch @panic("OOM");
- mod_roots.append(b.allocator, abs) catch @panic("OOM");
+ mod_names[module_index] = e.key_ptr.*;
+ mod_roots[module_index] = abs;
+ module_index += 1;
}
}
- opts.addOption([]const []const u8, "module_names", mod_names.items);
- opts.addOption([]const []const u8, "module_roots", mod_roots.items);
+ std.debug.assert(module_index == module_count);
+ opts.addOption([]const []const u8, "module_names", mod_names);
+ opts.addOption([]const []const u8, "module_roots", mod_roots);
root_mod.addOptions("pardes_config", opts);
hx_core_mod.addOptions("pardes_config", opts);
@@ -955,18 +934,32 @@ fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u
/// .toml or a .json is skipped, which is what lets the upstream LICENSE files
/// sit beside the themes they cover.
fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 {
+ var count: usize = 0;
+ {
+ var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes");
+ defer dir.close(io);
+ var it = dir.iterate();
+ while (it.next(io) catch @panic("read vendor/themes")) |e| {
+ if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue;
+ count += 1;
+ }
+ }
+
+ const names = b.allocator.alloc([]const u8, count) catch @panic("OOM");
var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes");
defer dir.close(io);
- var names: std.ArrayList([]const u8) = .empty;
var it = dir.iterate();
+ var index: usize = 0;
while (it.next(io) catch @panic("read vendor/themes")) |e| {
if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue;
- names.append(b.allocator, b.dupe(e.name)) catch @panic("OOM");
+ names[index] = b.dupe(e.name);
+ index += 1;
}
- std.mem.sort([]const u8, names.items, {}, struct {
+ std.debug.assert(index == names.len);
+ std.mem.sort([]const u8, names, {}, struct {
fn lt(_: void, a: []const u8, c: []const u8) bool {
return std.mem.order(u8, a, c) == .lt;
}
}.lt);
- return names.items;
+ return names;
}
diff --git a/mupdf.zig b/mupdf.zig
index c80f678d..a407ea09 100644
--- a/mupdf.zig
+++ b/mupdf.zig
@@ -248,17 +248,23 @@ fn flatCSources(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, re
std.debug.panic("MuPDF source directory is missing: {s}", .{relative_dir});
defer dir.close(io);
- var files: std.ArrayList([]const u8) = .empty;
+ const max_flat_sources = 4096;
+ var bounded: [max_flat_sources][]const u8 = undefined;
+ var count: usize = 0;
var it = dir.iterate();
while (it.next(io) catch @panic("read MuPDF source directory")) |entry| {
// Some filesystems report d_type as unknown. The selected directories
// are flat and immutable, so the suffix is the reliable predicate.
if (!std.mem.endsWith(u8, entry.name, ".c")) continue;
- files.append(b.allocator, b.fmt("{s}/{s}", .{ relative_dir, entry.name })) catch @panic("OOM");
+ if (count == bounded.len) std.debug.panic("MuPDF source directory exceeds {d} C files: {s}", .{ bounded.len, relative_dir });
+ bounded[count] = b.fmt("{s}/{s}", .{ relative_dir, entry.name });
+ count += 1;
}
- std.mem.sort([]const u8, files.items, {}, lessThan);
- if (files.items.len == 0) std.debug.panic("MuPDF source directory has no C files: {s}", .{relative_dir});
- return files.items;
+ if (count == 0) std.debug.panic("MuPDF source directory has no C files: {s}", .{relative_dir});
+ const files = b.allocator.alloc([]const u8, count) catch @panic("OOM");
+ @memcpy(files, bounded[0..count]);
+ std.mem.sort([]const u8, files, {}, lessThan);
+ return files;
}
fn readMakelists(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency) []const u8 {
@@ -271,7 +277,7 @@ fn readMakelists(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency) [
}
fn makeSources(b: *std.Build, makelists: []const u8, variable: []const u8) []const []const u8 {
- var files: std.ArrayList([]const u8) = .empty;
+ var count: usize = 0;
var lines = std.mem.splitScalar(u8, makelists, '\n');
while (lines.next()) |raw_line| {
const line = std.mem.trim(u8, raw_line, " \t\r");
@@ -279,11 +285,23 @@ fn makeSources(b: *std.Build, makelists: []const u8, variable: []const u8) []con
const assignment = std.mem.trimStart(u8, line[variable.len..], " \t");
if (!std.mem.startsWith(u8, assignment, "+=")) continue;
const path = std.mem.trim(u8, assignment[2..], " \t\r");
+ if (std.mem.endsWith(u8, path, ".c")) count += 1;
+ }
+ if (count == 0) std.debug.panic("MuPDF Makelists variable has no C sources: {s}", .{variable});
+ const files = b.allocator.alloc([]const u8, count) catch @panic("OOM");
+ lines = std.mem.splitScalar(u8, makelists, '\n');
+ var file_i: usize = 0;
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (!std.mem.startsWith(u8, line, variable)) continue;
+ const assignment = std.mem.trimStart(u8, line[variable.len..], " \t");
+ if (!std.mem.startsWith(u8, assignment, "+=")) continue;
+ const path = std.mem.trim(u8, assignment[2..], " \t\r");
if (!std.mem.endsWith(u8, path, ".c")) continue;
- files.append(b.allocator, b.dupe(path)) catch @panic("OOM");
+ files[file_i] = b.dupe(path);
+ file_i += 1;
}
- if (files.items.len == 0) std.debug.panic("MuPDF Makelists variable has no C sources: {s}", .{variable});
- return files.items;
+ return files;
}
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
diff --git a/src/allocators.zig b/src/allocators.zig
new file mode 100644
index 00000000..7a8433e8
--- /dev/null
+++ b/src/allocators.zig
@@ -0,0 +1,113 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const config = @import("pardes_config");
+
+const Allocator = std.mem.Allocator;
+const debug_enabled = builtin.mode == .Debug;
+const reduced_target = config.platform == .web or builtin.os.tag == .freestanding;
+const KiB = 1024;
+const MiB = 1024 * KiB;
+
+const capacities = struct {
+ const pardes = if (reduced_target) 8 * MiB else 32 * MiB;
+ const frame = if (reduced_target) 4 * MiB else 16 * MiB;
+ const tree_sitter = if (reduced_target) 4 * MiB else 16 * MiB;
+ const image = if (reduced_target) 64 * KiB else 32 * MiB;
+ const pdf = if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
+};
+
+pub const Allocators = struct {
+ pardes: Allocator,
+ frame: Allocator,
+ lsp: Allocator,
+ tree_sitter: Allocator,
+ image: Allocator,
+ pdf: Allocator,
+};
+
+var pardes_fallback: std.heap.StackFallbackAllocator(capacities.pardes) = undefined;
+var frame_fallback: std.heap.StackFallbackAllocator(capacities.frame) = undefined;
+var tree_sitter_fallback: std.heap.StackFallbackAllocator(capacities.tree_sitter) = undefined;
+var image_fallback: std.heap.StackFallbackAllocator(capacities.image) = undefined;
+var pdf_fallback: std.heap.StackFallbackAllocator(capacities.pdf) = undefined;
+
+const Debug = std.heap.DebugAllocator(.{});
+var pardes_debug: Debug = .init;
+var frame_debug: Debug = .init;
+var lsp_debug: Debug = .init;
+var tree_sitter_debug: Debug = .init;
+var image_debug: Debug = .init;
+var pdf_debug: Debug = .init;
+
+/// Returns ordinary allocators backed by stdlib fixed-buffer fallbacks. LSP
+/// keeps the caller's allocator because its detached workers are concurrent.
+pub fn init(fallback: Allocator) Allocators {
+ pardes_fallback.fallback_allocator = fallback;
+ pardes_fallback.get_called = if (std.debug.runtime_safety) false else {};
+ frame_fallback.fallback_allocator = fallback;
+ frame_fallback.get_called = if (std.debug.runtime_safety) false else {};
+ tree_sitter_fallback.fallback_allocator = fallback;
+ tree_sitter_fallback.get_called = if (std.debug.runtime_safety) false else {};
+ image_fallback.fallback_allocator = fallback;
+ image_fallback.get_called = if (std.debug.runtime_safety) false else {};
+ pdf_fallback.fallback_allocator = fallback;
+ pdf_fallback.get_called = if (std.debug.runtime_safety) false else {};
+
+ const raw: Allocators = .{
+ .pardes = pardes_fallback.get(),
+ .frame = frame_fallback.get(),
+ .lsp = fallback,
+ .tree_sitter = tree_sitter_fallback.get(),
+ .image = image_fallback.get(),
+ .pdf = pdf_fallback.get(),
+ };
+ if (!debug_enabled) return raw;
+
+ pardes_debug = .{ .backing_allocator = raw.pardes };
+ frame_debug = .{ .backing_allocator = raw.frame };
+ lsp_debug = .{ .backing_allocator = raw.lsp };
+ tree_sitter_debug = .{ .backing_allocator = raw.tree_sitter };
+ image_debug = .{ .backing_allocator = raw.image };
+ pdf_debug = .{ .backing_allocator = raw.pdf };
+ return .{
+ .pardes = pardes_debug.allocator(),
+ .frame = frame_debug.allocator(),
+ .lsp = lsp_debug.allocator(),
+ .tree_sitter = tree_sitter_debug.allocator(),
+ .image = image_debug.allocator(),
+ .pdf = pdf_debug.allocator(),
+ };
+}
+
+pub fn deinit() void {
+ if (!debug_enabled) return;
+ var leaked = pardes_debug.deinit() == .leak;
+ leaked = (frame_debug.deinit() == .leak) or leaked;
+ leaked = (lsp_debug.deinit() == .leak) or leaked;
+ leaked = (tree_sitter_debug.deinit() == .leak) or leaked;
+ leaked = (image_debug.deinit() == .leak) or leaked;
+ leaked = (pdf_debug.deinit() == .leak) or leaked;
+ if (leaked) @panic("allocator leaks detected");
+}
+
+test "fixed allocators are separate, spill, and restart" {
+ var allocs = init(std.testing.allocator);
+ const core = try allocs.pardes.alloc(u8, 32);
+ const frame = try allocs.frame.alloc(u8, 32);
+ try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(core.ptr));
+ try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr));
+ try std.testing.expect(core.ptr != frame.ptr);
+
+ const spill = try allocs.pardes.alloc(u8, capacities.pardes + 1);
+ try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr));
+ allocs.pardes.free(spill);
+ allocs.frame.free(frame);
+ allocs.pardes.free(core);
+ deinit();
+
+ allocs = init(std.testing.allocator);
+ defer deinit();
+ const restarted = try allocs.pardes.alloc(u8, 32);
+ defer allocs.pardes.free(restarted);
+ try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(restarted.ptr));
+}
diff --git a/src/builtins.zig b/src/builtins.zig
index ee542e77..3ccbdeed 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -54,36 +54,39 @@ pub const Ctx = struct {
arg: ?[]const u8,
};
-/// Every builtin, in the order they are written below — which is the enum's
-/// numeric order. Nothing reads that order: the topbar picks its own subset by
-/// name and Help sorts by key path, and nothing persists a builtin's integer
-/// (the dump stores tag WORDS), so reordering this file is free.
-///
-/// A FUNCTION and not a const, and so is Builtin below, for one reason: both
-/// walk this file's own declaration list, and a const doing that is a
-/// declaration whose value depends on itself — zig rejects it outright. As
-/// functions they are only ever a signature to the walk, never a value, so
-/// they are not in their own way.
-///
-/// The walk only ever sees `pub` decls, so the imports above are invisible to
-/// it; what it does see and turn away is `all`/`word`/`Builtin` (not types)
-/// and `Ctx` (a type, but with no `run`).
-pub fn all() []const type {
- // the whole body is comptime: a []const type only exists there, and it is
- // what makes the decl walk's `d` a compile-time name rather than a value
+/// Every enabled builtin, in source order. Feature gates are explicit data;
+/// a declaration that claims to be enabled but has the wrong run signature is
+/// a compile error instead of silently disappearing from the command enum.
+fn isEnabled(comptime T: type) bool {
+ return !@hasDecl(T, "enabled") or T.enabled;
+}
+
+fn builtinCount() comptime_int {
comptime {
- @setEvalBranchQuota(4000); // one pass per decl, and @hasDecl builds a map each
- var list: []const type = &.{};
+ var count = 0;
for (@typeInfo(@This()).@"struct".decls) |d| {
- // @TypeOf never evaluates its operand, so this turns away `all`
- // and `Builtin` by their SIGNATURES — asking for either one's
- // VALUE here would be a declaration that depends on itself
if (@TypeOf(@field(@This(), d.name)) != type) continue;
const T = @field(@This(), d.name);
- if (@typeInfo(T) != .@"struct") continue;
- if (!@hasDecl(T, "run")) continue;
- if (@TypeOf(T.run) != fn (Ctx) void) continue;
- list = list ++ &[_]type{T};
+ if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "run") or !isEnabled(T)) continue;
+ if (@TypeOf(T.run) != fn (Ctx) void)
+ @compileError(d.name ++ ".run must have signature fn (Ctx) void");
+ count += 1;
+ }
+ return count;
+ }
+}
+
+fn builtinList() [builtinCount()]type {
+ comptime {
+ @setEvalBranchQuota(4000);
+ var list: [builtinCount()]type = undefined;
+ var count = 0;
+ for (@typeInfo(@This()).@"struct".decls) |d| {
+ if (@TypeOf(@field(@This(), d.name)) != type) continue;
+ const T = @field(@This(), d.name);
+ if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "run") or !isEnabled(T)) continue;
+ list[count] = T;
+ count += 1;
}
return list;
}
@@ -100,16 +103,45 @@ pub fn word(comptime T: type) []const u8 {
return n[dot + 1 ..];
}
+pub const OutputTraits = struct {
+ name: []const u8,
+ steps: bool = false,
+ jumps: bool = false,
+ executes: bool = false,
+};
+
/// The enum: field name = struct name, value = index into `all()`. Everything
/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime
/// check, stringToEnum) speaks it exactly as it did when it was hand-written.
-pub fn Builtin() type {
- const cmds = all();
- const Tag = std.math.IntFittingRange(0, cmds.len - 1);
- var names: [cmds.len][]const u8 = undefined;
- for (cmds, 0..) |T, i| names[i] = word(T);
- return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len));
-}
+///
+/// Registry-dependent APIs live in one namespace so the outer declaration
+/// walk only sees this namespace's type, not functions whose signatures depend
+/// on the builtin enum being constructed.
+pub const registry = struct {
+ pub fn all() [builtinCount()]type {
+ return builtinList();
+ }
+
+ pub fn Builtin() type {
+ const cmds = builtinList();
+ const Tag = std.math.IntFittingRange(0, cmds.len - 1);
+ var names: [cmds.len][]const u8 = undefined;
+ for (cmds, 0..) |T, i| names[i] = word(T);
+ return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len));
+ }
+
+ pub fn takesArg(b: Builtin()) bool {
+ inline for (builtinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return @hasDecl(T, "takes_arg") and T.takes_arg;
+ unreachable;
+ }
+
+ pub fn outputTraits(b: Builtin()) ?OutputTraits {
+ inline for (builtinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return if (@hasDecl(T, "output")) T.output else null;
+ unreachable;
+ }
+};
// ---- the two acme verbs ----
@@ -128,12 +160,14 @@ pub fn Builtin() type {
// way `Save` on a terminal is inert.
pub const Look = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
c.p.lookAt(c.id, c.arg orelse return);
}
};
pub const Exec = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
// the destination pane is Look's business (it focuses what answered);
// an execute deliberately leaves you where you were
@@ -157,6 +191,7 @@ pub const Dump = struct {
};
pub const Restore = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
var it = std.mem.tokenizeAny(u8, c.txt, " \t");
_ = it.next(); // the word "Restore"
@@ -227,6 +262,7 @@ pub const NextColor = struct {
/// nobody will ever measure. 228 short string compares is microseconds, and it
/// happens once per theme change, not once per frame.
pub const Theme = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
for (pardes.themes, 0..) |t, i| {
@@ -248,6 +284,7 @@ pub const Theme = struct {
/// reason Theme has neither. Panes already open keep the shell they are
/// running; this is what the next one starts.
pub const Shell = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
// A name too long for the buffer is not silently truncated into a
@@ -265,8 +302,9 @@ pub const Shell = struct {
/// each theme in turn — the picker is the list, and there is nothing to
/// confirm because arriving already applied it.
pub const ThemeSel = struct {
+ pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .executes = true };
pub fn run(c: Ctx) void {
- output_pane.openThemes(c.p, c.id);
+ output_pane.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err);
}
};
@@ -304,7 +342,11 @@ pub const Crt = struct {
/// — so this asks: the resolved PATH goes in fonts.want, the shell takes it on
/// its next pass and re-rasters. Exactly the shape Restore already has.
pub const Font = struct {
- pub const run = if (pardes.platform == .gui) apply else {};
+ pub const takes_arg = true;
+ pub const enabled = pardes.platform == .gui;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
fn apply(c: Ctx) void {
const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
const hit = fonts.list(c.p.scratch.allocator(), want);
@@ -326,9 +368,13 @@ pub const Font = struct {
/// unreadable one — and this picker EXECUTES what it steps onto, so listing
/// them would mean the list wearing one on the way past. See fonts.monospaced.
pub const FontSel = struct {
- pub const run = if (pardes.platform == .gui) apply else {};
+ pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .executes = true };
+ pub const enabled = pardes.platform == .gui;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
fn apply(c: Ctx) void {
- output_pane.openFonts(c.p, c.id);
+ output_pane.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err);
}
};
@@ -338,7 +384,10 @@ pub const FontSel = struct {
/// omits it entirely, so the enum, Help and runtime binary contain no
/// PdfFit.
pub const PdfFit = struct {
- pub const run = if (pardes.pdf_enabled) apply else {};
+ pub const enabled = pardes.pdf_enabled;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
fn apply(c: Ctx) void {
c.p.togglePdfFit(c.pane);
}
@@ -348,7 +397,10 @@ pub const PdfFit = struct {
/// filter, and a full theme duotone. It has the same compile-time and
/// pane-local shape as PdfFit: absent without MuPDF and inert off a PDF pane.
pub const PdfTint = struct {
- pub const run = if (pardes.pdf_enabled) apply else {};
+ pub const enabled = pardes.pdf_enabled;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
fn apply(c: Ctx) void {
c.p.togglePdfTint(c.pane);
}
@@ -358,7 +410,11 @@ pub const PdfTint = struct {
/// command is absent from non-MuPDF builds and deliberately inert on every
/// other pane kind, like the two PDF display toggles above.
pub const PdfSections = struct {
- pub const run = if (pardes.pdf_enabled) apply else {};
+ pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true };
+ pub const enabled = pardes.pdf_enabled;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
fn apply(c: Ctx) void {
c.p.openPdfSections(c.id);
}
@@ -474,8 +530,9 @@ pub const Tutor = struct {
};
pub const Help = struct {
+ pub const output: OutputTraits = .{ .name = config.help_buffer };
pub fn run(c: Ctx) void {
- output_pane.openHelp(c.p, c.id, "");
+ output_pane.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err);
}
};
@@ -488,9 +545,12 @@ pub const Help = struct {
// they arm the same tag input `/` does, and Enter runs it (submitSearch).
pub const Find = struct {
+ pub const takes_arg = true;
+ pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return c.p.runSearch(c.id, pat, .find, .top);
+ if (pat.len > 0) return c.p.runSearch(c.id, pat, .find, .top) catch |err|
+ c.p.reportError(c.id, "find", err);
c.p.startSearch(c.pane, config.find_marker);
}
};
@@ -498,9 +558,12 @@ pub const Find = struct {
/// Find's sibling: Find matches file NAMES under this pane's directory, Grep
/// matches file CONTENTS under every pane's directory at once.
pub const Grep = struct {
+ pub const takes_arg = true;
+ pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top);
+ if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top) catch |err|
+ c.p.reportError(c.id, "grep", err);
c.p.startSearch(c.pane, config.grep_marker);
}
};
@@ -590,8 +653,9 @@ pub const Last = struct {
/// second list kept in step — the buffer is RENDERED from the stack when you
/// ask, the way +Search is rendered from a walk.
pub const Jumplist = struct {
+ pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true };
pub fn run(c: Ctx) void {
- output_pane.openJumps(c.p, c.id);
+ output_pane.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err);
}
};
@@ -649,6 +713,7 @@ pub const WsDiagnostics = struct {
// no argument arms the tag input and Enter submits (submitSearch).
pub const Rename = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .rename, a);
@@ -657,6 +722,7 @@ pub const Rename = struct {
};
pub const WsSymbols = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a);
diff --git a/src/config.zig b/src/config.zig
index 76d63727..3c9664f0 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -27,7 +27,7 @@ const pardes = @import("pardes.zig");
const builtins = @import("builtins.zig");
const Key = pardes.Key;
const Mouse = pardes.Mouse;
-const Builtin = builtins.Builtin();
+const Builtin = builtins.registry.Builtin();
/// One key press a binding matches. `shift` is only consulted when a binding
/// ASKS for it: shift is already carried in the codepoint for anything
diff --git a/src/file_pane.zig b/src/file_pane.zig
index b06599ae..c0a2350d 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -43,9 +43,8 @@ pub fn lineCount(content: []const u8) usize {
/// is what they already did when their own arena ran out.
pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize {
if (f.line_starts.len > 0) return f.line_starts;
- // exact alloc, not an ArrayList: deinitPane frees `line_starts` itself, and
- // an ArrayList's items slice is shorter than its allocation whenever the
- // growth strategy rounded up — freeing that is a wrong-size free.
+ // Exact allocation: deinitPane frees `line_starts` itself, so the stored
+ // slice must span the complete allocation rather than spare capacity.
const starts = try gpa.alloc(usize, lineCount(f.content));
starts[0] = 0;
var i: usize = 1;
@@ -81,6 +80,7 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
const total = lineCount(content);
const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
+ pane.kind = .file;
pane.cur_pinned = true;
pane.cur_row = @intCast(scroll);
// watches follow pane lifetime: this is the only place a real file is read
@@ -100,7 +100,7 @@ pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
// the highlights go too, and not just because they are stale: their byte
// range is what refreshHighlights tests a scroll against, and a range
// measured on the OLD content would let it skip a re-parse it needs
- if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = &.{};
f.highlight_start = 0;
f.syntax_dirty = true;
@@ -133,7 +133,7 @@ pub fn refreshHighlights(p: *Pardes) void {
const f = &pane.file.?;
if (!f.syntax_dirty) continue;
if (!p.colors_on) {
- if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = &.{};
f.highlight_start = 0;
f.syntax_dirty = false;
@@ -168,11 +168,11 @@ pub fn refreshHighlights(p: *Pardes) void {
const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
const start = lineStart(p.gpa, f, f.scroll -| slack);
const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
- const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch {
+ const new_highlights = syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end) catch {
f.syntax_dirty = false;
continue;
};
- if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = new_highlights;
f.highlight_start = if (f.highlights.len > 0) start else 0;
f.syntax_dirty = false;
@@ -190,7 +190,6 @@ pub fn refreshHighlights(p: *Pardes) void {
/// built. `wrap_n` stays 0 for an unwrapped body, and that is the value the
/// readers treat as "rows are lines", so the off path never consults an array.
pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]const u8 {
- var out: std.ArrayList(u8) = .empty;
// the pane's own content width, less the ONE column the break marker sits
// in — a wrapped row that filled its last cell would have nowhere to say it
// wrapped. 0 means don't wrap. A pane taller than the map refuses too,
@@ -200,6 +199,21 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
else
@max(1, @as(usize, pane.cols -| config.PREFIX_W) -| 1);
pane.wrap_n = 0;
+
+ // Count the exact rendered bytes first. Unwrapped source lines are not
+ // bounded by the pane width, so a rows*cols buffer would either truncate
+ // them or quietly restore a growable builder under another name.
+ const len = fillBody(null, pane, f, width, false);
+ const out = try arena.alloc(u8, len);
+ const filled = fillBody(out, pane, f, width, true);
+ std.debug.assert(filled == out.len);
+ return out;
+}
+
+/// Run the file-body row walk. With no destination it is the exact sizing
+/// pass; with one it fills that allocation and records the wrapping map.
+fn fillBody(dst: ?[]u8, pane: *Pane, f: *File, width: usize, record_wrap: bool) usize {
+ if (record_wrap) pane.wrap_n = 0;
// start ON the first visible line instead of walking the file to it: this
// walk was O(f.scroll) and recolorSyntax below ran the identical one again
var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
@@ -212,15 +226,17 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
var abs: i32 = @intCast(f.scroll);
var at: usize = 0;
var cur = flines.next();
- var i: usize = 0;
- while (i < pane.rows) : (i += 1) {
- if (i > 0) try out.append(arena, '\n');
- if (width > 0) {
+ var written: usize = 0;
+ for (0..pane.rows) |i| {
+ if (i > 0) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
+ if (width > 0 and record_wrap) {
pane.wrap_line[i] = abs;
pane.wrap_col[i] = @intCast(at);
pane.wrap_n = @intCast(i + 1);
}
- const row_start = out.items.len;
if (cur) |text| {
var lbuf: [16]u8 = undefined;
// unsigned: {d} prints a leading '+' for signed ints
@@ -232,7 +248,9 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
" "
else
std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
- try out.appendSlice(arena, prefix);
+ if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix);
+ written += prefix.len;
+
// A byte cut, like the hscroll one below, and it can land inside a
// multi-byte glyph for the same reason: columns here are BYTES.
// Surface.print decodes by hand and emits U+FFFD per undecodable
@@ -241,7 +259,13 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
// the pane either: a UTF-8 sequence is never fewer bytes than the
// cells it draws in.
const take = if (width == 0) text.len - at else @min(width, text.len - at);
- try out.appendSlice(arena, text[at .. at + take]);
+ const cut = if (pane.hscroll > 0 and width == 0)
+ @min(@as(usize, @intCast(pane.hscroll)), take)
+ else
+ 0;
+ const shown = text[at + cut .. at + take];
+ if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
+ written += shown.len;
if (width > 0 and at + take < text.len) {
at += take;
} else {
@@ -250,17 +274,8 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
cur = flines.next();
}
} else abs += 1;
- // horizontal scroll: cut hscroll columns after the gutter. Wrapped
- // there is nothing to scroll to, so the cut is not made at all.
- if (pane.hscroll > 0 and width == 0) {
- const cut_at = row_start + config.PREFIX_W;
- if (out.items.len > cut_at) {
- const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at);
- out.replaceRangeAssumeCapacity(cut_at, cut, &.{});
- }
- }
}
- return out.items;
+ return written;
}
/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not
diff --git a/src/grammar_manifest.zig b/src/grammar_manifest.zig
new file mode 100644
index 00000000..bd89e0a0
--- /dev/null
+++ b/src/grammar_manifest.zig
@@ -0,0 +1,44 @@
+//! Dependency-free tree-sitter grammar metadata shared by build.zig and the
+//! runtime syntax registry. Keep target symbols and query values out of this
+//! file: the build runner imports it as host code.
+
+pub const Tier = enum { zig, minimal, full };
+
+pub const Grammar = struct {
+ name: []const u8,
+ dep: []const u8,
+ exts: []const []const u8,
+ tier: Tier,
+ src: []const u8 = "src",
+ scanner: bool = false,
+ query: []const u8 = "queries/highlights.scm",
+};
+
+pub const all = [_]Grammar{
+ .{ .name = "ada", .dep = "ts_ada", .exts = &.{ ".adb", ".ads", ".ada" }, .tier = .full },
+ .{ .name = "bash", .dep = "ts_bash", .exts = &.{ ".sh", ".bash", ".zsh" }, .tier = .full, .scanner = true },
+ .{ .name = "c", .dep = "ts_c", .exts = &.{ ".c", ".h" }, .tier = .minimal },
+ .{ .name = "c_sharp", .dep = "ts_c_sharp", .exts = &.{ ".cs", ".csx" }, .tier = .full, .scanner = true },
+ .{ .name = "clojure", .dep = "ts_clojure", .exts = &.{ ".clj", ".cljs", ".cljc", ".edn" }, .tier = .full },
+ .{ .name = "cpp", .dep = "ts_cpp", .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .tier = .minimal, .scanner = true },
+ .{ .name = "css", .dep = "ts_css", .exts = &.{".css"}, .tier = .full, .scanner = true },
+ .{ .name = "elixir", .dep = "ts_elixir", .exts = &.{ ".ex", ".exs" }, .tier = .full, .scanner = true },
+ .{ .name = "erlang", .dep = "ts_erlang", .exts = &.{ ".erl", ".hrl" }, .tier = .full, .scanner = true },
+ .{ .name = "fortran", .dep = "ts_fortran", .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .tier = .full, .scanner = true },
+ .{ .name = "go", .dep = "ts_go", .exts = &.{".go"}, .tier = .full },
+ .{ .name = "haskell", .dep = "ts_haskell", .exts = &.{ ".hs", ".lhs" }, .tier = .full, .scanner = true },
+ .{ .name = "html", .dep = "ts_html", .exts = &.{ ".html", ".htm" }, .tier = .full, .scanner = true },
+ .{ .name = "java", .dep = "ts_java", .exts = &.{".java"}, .tier = .full },
+ .{ .name = "javascript", .dep = "ts_javascript", .exts = &.{ ".js", ".jsx", ".mjs", ".cjs" }, .tier = .full, .scanner = true },
+ .{ .name = "json", .dep = "ts_json", .exts = &.{".json"}, .tier = .full },
+ .{ .name = "kotlin", .dep = "ts_kotlin", .exts = &.{ ".kt", ".kts" }, .tier = .full, .scanner = true },
+ .{ .name = "ocaml", .dep = "ts_ocaml", .exts = &.{ ".ml", ".mli" }, .tier = .full, .src = "grammars/ocaml/src", .scanner = true },
+ .{ .name = "pascal", .dep = "ts_pascal", .exts = &.{ ".pas", ".pp", ".p" }, .tier = .full },
+ .{ .name = "php", .dep = "ts_php", .exts = &.{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .tier = .full, .src = "php/src", .scanner = true },
+ .{ .name = "powershell", .dep = "ts_powershell", .exts = &.{ ".ps1", ".psm1", ".psd1" }, .tier = .full, .scanner = true },
+ .{ .name = "python", .dep = "ts_python", .exts = &.{ ".py", ".pyw" }, .tier = .full, .scanner = true },
+ .{ .name = "ruby", .dep = "ts_ruby", .exts = &.{ ".rb", ".rake" }, .tier = .full, .scanner = true },
+ .{ .name = "rust", .dep = "ts_rust", .exts = &.{".rs"}, .tier = .full, .scanner = true },
+ .{ .name = "scala", .dep = "ts_scala", .exts = &.{ ".scala", ".sc" }, .tier = .full, .scanner = true },
+ .{ .name = "zig", .dep = "ts_zig", .exts = &.{ ".zig", ".zon" }, .tier = .zig },
+};
diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig
index e21f4674..1ad7dd84 100644
--- a/src/gui/fonts.zig
+++ b/src/gui/fonts.zig
@@ -65,7 +65,8 @@ pub var want: ?[]const u8 = null;
/// four in vendor/stb/font.c plus a UTF-16BE decode, and this file is where it
/// would land.
pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
- var out: std.ArrayList(Font) = .empty;
+ var found: [max_fonts]Font = undefined;
+ var found_len: usize = 0;
// Zig 0.16 moved the filesystem behind std.Io; the blocking
// single-threaded implementation is the synchronous walk a sans-IO core
// wants, the same one look.find uses.
@@ -87,7 +88,7 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
var w = dir.walkSelectively(arena) catch continue;
defer w.deinit();
var steps: usize = 0;
- while (steps < max_steps and out.items.len < max_fonts) {
+ while (steps < max_steps and found_len < found.len) {
steps += 1; // an unreadable dir burns a step too, so it cannot spin
const e = (w.next(io) catch continue) orelse break;
if (e.kind == .directory) {
@@ -103,21 +104,22 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
if (want_name) |wn| {
if (!std.mem.eql(u8, wn, name)) continue;
} else if (!monospaced(path)) continue;
- out.append(arena, .{
+ found[found_len] = .{
.name = arena.dupe(u8, name) catch break,
.path = arena.dupe(u8, path) catch break,
- }) catch break;
- if (want_name != null) return out.items; // asked for one, found it
+ };
+ found_len += 1;
+ if (want_name != null) return arena.dupe(Font, found[0..found_len]) catch &.{}; // asked for one, found it
}
}
// readdir order is undefined; sort so the picker is the same list twice
// running and n/N walks a font's own variants in a row
- std.mem.sort(Font, out.items, {}, struct {
+ std.mem.sort(Font, found[0..found_len], {}, struct {
fn lt(_: void, a: Font, b: Font) bool {
return std.mem.lessThan(u8, a.name, b.name);
}
}.lt);
- return out.items;
+ return arena.dupe(Font, found[0..found_len]) catch &.{};
}
/// Is every glyph in this font the same width? The terminal grid IS a
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 39d8c575..951564f0 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -560,6 +560,19 @@ const Msg = union(enum) {
/// a pardes launched inside this one sent us a builtin command line (see
/// lookThread); gpa-owned, like `output` bytes
command: []u8,
+
+ fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void {
+ switch (m) {
+ .output => |o| gpa.free(o.bytes),
+ .lsp => |l| lsp_allocator.free(l.rows),
+ .pipe => |response_value| {
+ var response = response_value;
+ response.deinit(gpa);
+ },
+ .command => |line| gpa.free(line),
+ .eof, .files_changed => {},
+ }
+ }
};
/// The files on open panes, watched through ONE inotify instance. Same shape
@@ -590,54 +603,133 @@ const LspJob = struct {
}
};
+const LspWorkers = struct {
+ active: std.atomic.Value(usize) = .init(0),
+
+ fn start(workers: *LspWorkers) void {
+ _ = workers.active.fetchAdd(1, .monotonic);
+ }
+
+ fn finish(workers: *LspWorkers) void {
+ _ = workers.active.fetchSub(1, .release);
+ }
+
+ fn wait(workers: *LspWorkers) void {
+ var spins: u8 = 0;
+ while (workers.active.load(.acquire) != 0) {
+ spins +%= 1;
+ if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint();
+ }
+ }
+};
+
+const max_pipe_tasks = 16;
+
const PipeTask = struct {
id: u32,
future: std.Io.Future(anyerror!void),
};
+const PipeTasks = struct {
+ items: [max_pipe_tasks]PipeTask = undefined,
+ len: usize = 0,
+
+ fn add(tasks: *PipeTasks, task: PipeTask) bool {
+ if (tasks.len == tasks.items.len) return false;
+ tasks.items[tasks.len] = task;
+ tasks.len += 1;
+ return true;
+ }
+
+ fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void {
+ for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) {
+ task.future.await(io) catch {};
+ tasks.len -= 1;
+ std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]);
+ return;
+ };
+ }
+
+ fn cancelAll(tasks: *PipeTasks, io: std.Io) void {
+ for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {};
+ tasks.len = 0;
+ }
+};
+
+const queue_capacity = 512;
+
+const MessageBatch = struct {
+ items: [queue_capacity]Msg = undefined,
+ len: usize = 0,
+
+ fn slice(batch: *MessageBatch) []Msg {
+ return batch.items[0..batch.len];
+ }
+};
+
const Queue = struct {
gpa: std.mem.Allocator,
+ lsp_allocator: std.mem.Allocator,
+ lsp_workers: *LspWorkers,
sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push
// 0.16 has no std.Thread.Mutex; critical sections here are a few
- // instructions, so spinning on the lock-free std.atomic.Mutex is enough
+ // instructions, so spinning on the lock-free std.atomic.Mutex is enough.
mutex: std.atomic.Mutex = .unlocked,
- items: std.ArrayList(Msg) = .empty,
+ items: [queue_capacity]Msg = undefined,
+ head: usize = 0,
+ len: usize = 0,
closed: bool = false,
fn lock(q: *Queue) void {
while (!q.mutex.tryLock()) std.atomic.spinLoopHint();
}
+ fn removeAt(q: *Queue, offset: usize) Msg {
+ const removed = q.items[(q.head + offset) % q.items.len];
+ var i = offset;
+ while (i + 1 < q.len) : (i += 1)
+ q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len];
+ q.len -= 1;
+ return removed;
+ }
+
+ /// Output and other refreshable work are lossy under sustained
+ /// backpressure. EOF and pipe completions are admitted by evicting queued
+ /// non-critical messages, so descriptors and futures reach the loop.
fn push(q: *Queue, m: Msg) void {
q.lock();
if (q.closed) {
q.mutex.unlock();
- switch (m) {
- .output => |o| q.gpa.free(o.bytes),
- .lsp => |l| q.gpa.free(l.rows),
- .pipe => |response_value| {
- var response = response_value;
- response.deinit(q.gpa);
- },
- .command => |line| q.gpa.free(line),
- .eof, .files_changed => {},
- }
+ m.deinit(q.gpa, q.lsp_allocator);
return;
}
- q.items.append(q.gpa, m) catch {
- q.mutex.unlock();
- switch (m) {
- .output => |o| q.gpa.free(o.bytes),
- .lsp => |l| q.gpa.free(l.rows),
- .pipe => |response_value| {
- var response = response_value;
- response.deinit(q.gpa);
- },
- .command => |line| q.gpa.free(line),
- .eof, .files_changed => {},
+ if (q.len == q.items.len) {
+ const incoming_critical = switch (m) {
+ .pipe, .eof => true,
+ else => false,
+ };
+ if (!incoming_critical) {
+ q.mutex.unlock();
+ m.deinit(q.gpa, q.lsp_allocator);
+ return;
}
- return;
- };
+ var offset: usize = 0;
+ while (offset < q.len) : (offset += 1) {
+ const queued_critical = switch (q.items[(q.head + offset) % q.items.len]) {
+ .pipe, .eof => true,
+ else => false,
+ };
+ if (!queued_critical) break;
+ }
+ if (offset == q.len) {
+ q.mutex.unlock();
+ m.deinit(q.gpa, q.lsp_allocator);
+ return;
+ }
+ q.removeAt(offset).deinit(q.gpa, q.lsp_allocator);
+ }
+ q.items[(q.head + q.len) % q.items.len] = m;
+ q.len += 1;
q.mutex.unlock();
if (q.sdl_wake) {
var sev = std.mem.zeroes(c.SDL_Event);
@@ -646,30 +738,41 @@ const Queue = struct {
}
}
- /// take the pending messages (caller iterates + deinits outside the lock)
- fn take(q: *Queue) std.ArrayList(Msg) {
+ fn take(q: *Queue) MessageBatch {
q.lock();
defer q.mutex.unlock();
- const local = q.items;
- q.items = .empty;
- return local;
+ var batch: MessageBatch = .{};
+ while (q.len > 0) {
+ batch.items[batch.len] = q.items[q.head];
+ batch.len += 1;
+ q.head = (q.head + 1) % q.items.len;
+ q.len -= 1;
+ }
+ q.head = 0;
+ return batch;
}
- fn close(q: *Queue) void {
+ fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void {
q.lock();
defer q.mutex.unlock();
q.closed = true;
- for (q.items.items) |m| switch (m) {
- .output => |o| q.gpa.free(o.bytes),
- .lsp => |l| q.gpa.free(l.rows),
- .pipe => |response_value| {
- var response = response_value;
- response.deinit(q.gpa);
- },
- .command => |line| q.gpa.free(line),
- .eof, .files_changed => {},
- };
- q.items.deinit(q.gpa);
+ while (q.len > 0) {
+ const m = q.items[q.head];
+ switch (m) {
+ .eof => |e| {
+ _ = libc.close(e.fd);
+ if (gens[e.pane] == e.gen) {
+ if (ptys[e.pane]) |pt| if (pt.fd == e.fd) {
+ ptys[e.pane] = null;
+ };
+ }
+ },
+ else => m.deinit(q.gpa, q.lsp_allocator),
+ }
+ q.head = (q.head + 1) % q.items.len;
+ q.len -= 1;
+ }
+ q.head = 0;
}
};
@@ -790,14 +893,15 @@ fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes
/// Answer a language query off the render loop and push the rows to the queue
/// — the async execution model, spelled in the plumbing this shell already has
/// (a detached thread and the mutex queue the pty readers use).
-fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
- defer job.free(gpa);
- var arena: std.heap.ArenaAllocator = .init(gpa);
+fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJob, q: *Queue) void {
+ defer workers.finish();
+ defer job.free(lsp_allocator);
+ var arena: std.heap.ArenaAllocator = .init(lsp_allocator);
defer arena.deinit();
// the shell owns the result buffer; the backend only ever writes to it
- var out: std.Io.Writer.Allocating = .init(gpa);
+ var out: std.Io.Writer.Allocating = .init(lsp_allocator);
defer out.deinit();
- pardes.lsp.query(gpa, arena.allocator(), .{
+ pardes.lsp.query(lsp_allocator, arena.allocator(), .{
.kind = job.kind,
.path = job.path,
.source = job.source,
@@ -805,48 +909,51 @@ fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
.arg = job.arg,
.root = job.root,
}, &out.writer);
- const rows = gpa.dupe(u8, out.written()) catch return;
+ const rows = lsp_allocator.dupe(u8, out.written()) catch return;
q.push(.{ .lsp = .{ .id = job.id, .rows = rows } });
}
/// Copy the query out of the core and hand it to a thread. A detached thread
/// per query is fine at this rate: one keystroke, one query, and the queue
/// already tolerates a late push after close.
-fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void {
+fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: anytype) void {
+ const lsp_allocator = q.lsp_allocator;
const pane = core.panes[e.pane] orelse return;
// a pane with no file still asks `status` (it is about the backend, not
// the buffer): empty path and source, root from the pane's cwd
const f = pane.file;
- const job = gpa.create(LspJob) catch return;
+ const job = lsp_allocator.create(LspJob) catch return;
job.* = .{
.id = e.id,
.kind = e.kind,
.offset = e.offset,
- .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch {
- gpa.destroy(job);
+ .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch {
+ lsp_allocator.destroy(job);
return;
},
- .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch {
- gpa.free(job.path);
- gpa.destroy(job);
+ .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.destroy(job);
return;
},
- .arg = gpa.dupe(u8, e.arg.slice()) catch {
- gpa.free(job.path);
- gpa.free(job.source);
- gpa.destroy(job);
+ .arg = lsp_allocator.dupe(u8, e.arg.slice()) catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.free(job.source);
+ lsp_allocator.destroy(job);
return;
},
- .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
- gpa.free(job.path);
- gpa.free(job.source);
- gpa.free(job.arg);
- gpa.destroy(job);
+ .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.free(job.source);
+ lsp_allocator.free(job.arg);
+ lsp_allocator.destroy(job);
return;
},
};
- const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch {
- job.free(gpa);
+ q.lsp_workers.start();
+ const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch {
+ q.lsp_workers.finish();
+ job.free(lsp_allocator);
return;
};
th.detach();
@@ -865,20 +972,20 @@ fn spawnPipe(
io: std.Io,
gpa: std.mem.Allocator,
q: *Queue,
- tasks: *std.ArrayList(PipeTask),
+ tasks: *PipeTasks,
request: anytype,
) void {
+ if (tasks.len == tasks.items.len) {
+ core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } });
+ return;
+ }
const view = core.pipeRequest(request.id) orelse return;
const job = selection_pipe.Job.copy(gpa, view) catch return;
- tasks.ensureUnusedCapacity(gpa, 1) catch {
- job.deinit(gpa);
- return;
- };
const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch {
job.deinit(gpa);
return;
};
- tasks.appendAssumeCapacity(.{ .id = request.id, .future = future });
+ std.debug.assert(tasks.add(.{ .id = request.id, .future = future }));
}
// ---- the renderer state ----
@@ -1205,8 +1312,16 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
g.pen_x = cell_w;
// ---- the core ----
- pardes.image.start(io, gpa); // stb_image allocator for image panes
- defer pardes.image.stop();
+ const allocs = pardes.allocators.init(gpa);
+ defer pardes.allocators.deinit();
+ pardes.image.start(io, allocs.image); // stb_image allocator for image panes
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ pardes.syntax.start(allocs.tree_sitter);
+ defer {
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ }
// Live sessions initialize at the default 80x24 grid; the real window size
// arrives as a resize EVENT on the first loop pass. The core defers the
// shell greeting until after the first resize (so `ls` wraps to the real
@@ -1215,6 +1330,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// Dump loads pre-size instead: replayed panes never greet, and sizing at
// init avoids reflowing their replayed content twice.
var opts = opts_in;
+ opts.image_allocator = allocs.image;
+ opts.pdf_allocator = allocs.pdf;
+ opts.tree_sitter_allocator = allocs.tree_sitter;
var pw: c_int = 0;
var ph: c_int = 0;
if (opts.load_path != null) {
@@ -1225,8 +1343,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var core = if (opts.load_path) |lp| blk: {
const bytes = try @import("../look.zig").readFile(gpa, lp);
defer gpa.free(bytes);
- break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
- } else try pardes.Pardes.init(gpa, opts);
+ break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes);
+ } else try pardes.Pardes.init(allocs.pardes, opts);
defer core.deinit();
// SDL is itself a native-pixel backend. This is deliberately set after
// construction: argv image panes no longer freeze the startup capability
@@ -1246,15 +1364,20 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var gens: [pardes.MAX_PANES]u32 = @splat(0);
defer for (&ptys) |*slot| if (slot.*) |pt| {
_ = libc.close(pt.fd);
- slot.* = null;
};
- var queue: Queue = .{ .gpa = gpa, .sdl_wake = true };
- defer queue.close();
- var pipe_tasks: std.ArrayList(PipeTask) = .empty;
+ var lsp_workers: LspWorkers = .{};
+ var queue: Queue = .{
+ .gpa = gpa,
+ .lsp_allocator = allocs.lsp,
+ .lsp_workers = &lsp_workers,
+ .sdl_wake = true,
+ };
defer {
- for (pipe_tasks.items) |*task| task.future.cancel(io) catch {};
- pipe_tasks.deinit(gpa);
+ lsp_workers.wait();
+ queue.close(&ptys, &gens);
}
+ var pipe_tasks: PipeTasks = .{};
+ defer pipe_tasks.cancelAll(io);
// One inotify instance for every watched pane, opened here — before any
// thread exists — so the pre-loop drain below can already mark the file a
// positional path argument opened. -1 off linux: watchPane goes quiet and
@@ -1289,7 +1412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var feed: StdinFeed = .{};
if (test_mode) setStdinRaw() catch {};
- var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
var animation_clock: AnimationClock = .{};
@@ -1308,7 +1431,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// 2. pty output from the reader threads
var msgs = queue.take();
var check_files = false;
- for (msgs.items) |m| switch (m) {
+ for (msgs.slice()) |m| switch (m) {
.output => |o| {
if (gens[o.pane] == o.gen)
core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
@@ -1323,7 +1446,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
},
.lsp => |l| {
core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
- gpa.free(l.rows);
+ allocs.lsp.free(l.rows);
},
.pipe => |response_value| {
var response = response_value;
@@ -1333,11 +1456,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.outputs = response.outputs,
} });
response.deinit(gpa);
- for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) {
- task.future.await(io) catch {};
- _ = pipe_tasks.orderedRemove(i);
- break;
- };
+ pipe_tasks.finish(io, response_value.id);
},
.command => |line| {
core.update(.{ .command = line });
@@ -1348,7 +1467,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// a reload — or an undo entry — per write.
.files_changed => check_files = true,
};
- msgs.deinit(gpa);
if (check_files) reloadChanged(core, gpa, &watches);
// 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
pollGamepad(&g, core);
@@ -1364,7 +1482,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var o = core.opts;
o.cols = core.screen_w;
o.rows = core.screen_h; // pre-size: dump panes never greet
- const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk;
+ const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk;
for (&ptys) |*slot| if (slot.*) |pt| {
_ = libc.kill(pt.pid, libc.SIG.KILL);
slot.* = null;
@@ -1656,7 +1774,7 @@ fn runWeb(opts_in: pardes.Options) !void {
},
.core = undefined,
.gpa = gpa,
- .frame_arena = .init(gpa),
+ .frame_arena = undefined,
};
const g = &st.g;
// slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
@@ -1664,15 +1782,27 @@ fn runWeb(opts_in: pardes.Options) !void {
g.pen_x = cell_w;
// ---- the core: state replays from the embedded dump, no ptys ----
- pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm
+ const allocs = pardes.allocators.init(gpa);
+ errdefer pardes.allocators.deinit();
+ st.frame_arena = .init(allocs.frame);
+ errdefer st.frame_arena.deinit();
+ pardes.image.start(std.Io.failing, allocs.image); // decode is synchronous; no io on wasm
+ errdefer pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.start(allocs.tree_sitter);
+ errdefer pardes.syntax.stop();
syncCanvasSize(g); // size the backing canvas before deriving the grid
var opts = opts_in;
+ opts.image_allocator = allocs.image;
+ opts.pdf_allocator = allocs.pdf;
+ opts.tree_sitter_allocator = allocs.tree_sitter;
var pw: c_int = 0;
var ph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
- st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump);
+ st.core = try pardes.Pardes.initFromDump(allocs.pardes, opts, embedded_dump);
live_web_state = st;
_ = c.SDL_StartTextInput(window);
@@ -1688,7 +1818,17 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void {
while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
// 2. effects
drainEffectsWeb(core, st.gpa, g);
- if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop();
+ if (core.quit) {
+ std.os.emscripten.emscripten_cancel_main_loop();
+ live_web_state = null;
+ core.deinit();
+ st.frame_arena.deinit();
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ pardes.allocators.deinit();
+ return;
+ }
// 3. the backing canvas follows the page, the grid follows the canvas
syncCanvasSize(g);
var pw: c_int = 0;
@@ -1730,12 +1870,23 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
const gpa = init.gpa;
const env = init.environ_map;
- pardes.image.start(io, gpa);
- defer pardes.image.stop();
+ const allocs = pardes.allocators.init(gpa);
+ defer pardes.allocators.deinit();
+ pardes.image.start(io, allocs.image);
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ pardes.syntax.start(allocs.tree_sitter);
+ defer {
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ }
const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
var opts = opts_in;
+ opts.image_allocator = allocs.image;
+ opts.pdf_allocator = allocs.pdf;
+ opts.tree_sitter_allocator = allocs.tree_sitter;
if (opts.load_path != null) {
opts.cols = grid_cols;
opts.rows = grid_rows;
@@ -1743,8 +1894,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
const core = if (opts.load_path) |lp| blk: {
const bytes = try @import("../look.zig").readFile(gpa, lp);
defer gpa.free(bytes);
- break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
- } else try pardes.Pardes.init(gpa, opts);
+ break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes);
+ } else try pardes.Pardes.init(allocs.pardes, opts);
defer core.deinit();
// the requested grid arrives as a resize EVENT (not init opts) so the core
// counts a resize and releases the shell greeting on first output
@@ -1763,15 +1914,20 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
var gens: [pardes.MAX_PANES]u32 = @splat(0);
defer for (&ptys) |*slot| if (slot.*) |pt| {
_ = libc.close(pt.fd);
- slot.* = null;
};
- var queue: Queue = .{ .gpa = gpa, .sdl_wake = false };
- defer queue.close();
- var pipe_tasks: std.ArrayList(PipeTask) = .empty;
+ var lsp_workers: LspWorkers = .{};
+ var queue: Queue = .{
+ .gpa = gpa,
+ .lsp_allocator = allocs.lsp,
+ .lsp_workers = &lsp_workers,
+ .sdl_wake = false,
+ };
defer {
- for (pipe_tasks.items) |*task| task.future.cancel(io) catch {};
- pipe_tasks.deinit(gpa);
+ lsp_workers.wait();
+ queue.close(&ptys, &gens);
}
+ var pipe_tasks: PipeTasks = .{};
+ defer pipe_tasks.cancelAll(io);
// no inotify here on purpose: this mode's whole contract is one frame per
// scripted input event, and a reload that arrives on its own clock would
// put a frame in the stream nothing asked for. -1 makes watchPane a no-op.
@@ -1781,7 +1937,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
- var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
var feed: StdinFeed = .{};
@@ -1795,7 +1951,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
if (r.eof) break;
var n_events: usize = r.n_events;
var msgs = queue.take();
- for (msgs.items) |m| switch (m) {
+ for (msgs.slice()) |m| switch (m) {
.output => |o| {
if (gens[o.pane] == o.gen)
core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
@@ -1812,7 +1968,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
},
.lsp => |l| {
core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
- gpa.free(l.rows);
+ allocs.lsp.free(l.rows);
n_events += 1;
},
.pipe => |response_value| {
@@ -1824,17 +1980,12 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
} });
response.deinit(gpa);
n_events += 1;
- for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) {
- task.future.await(io) catch {};
- _ = pipe_tasks.orderedRemove(i);
- break;
- };
+ pipe_tasks.finish(io, response_value.id);
},
// both unreachable here: this mode starts neither thread
.files_changed => {},
.command => |line| gpa.free(line),
};
- msgs.deinit(gpa);
drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true);
pollCwds(core, &ptys);
// The grid harness polls stdin at the same 16 ms cadence as native
@@ -2062,27 +2213,48 @@ fn setStdinRaw() !void {
// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing.
fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
if (surface.cols == 0 or surface.rows == 0) return;
- // build the whole frame in one buffer → one write → atomic snapshots
- var buf: std.ArrayList(u8) = .empty;
- defer buf.deinit(gpa);
const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0;
const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0;
var hdr: [128]u8 = undefined;
- const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{
+ const header = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{
surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null,
}) catch return;
- try buf.appendSlice(gpa, hdr_str);
+ const footer = "---ENDFRAME---\n";
+ var total = try std.math.add(usize, header.len, footer.len);
+ total = try std.math.add(usize, total, surface.rows);
var y: u16 = 0;
while (y < surface.rows) : (y += 1) {
var x: u16 = 0;
while (x < surface.cols) : (x += 1) {
- const g = surface.at(x, y).grapheme();
- if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' ');
+ const grapheme = surface.at(x, y).grapheme();
+ total = try std.math.add(usize, total, if (grapheme.len > 0) grapheme.len else 1);
+ }
+ }
+ const frame = try gpa.alloc(u8, total);
+ defer gpa.free(frame);
+ var at: usize = 0;
+ @memcpy(frame[at..][0..header.len], header);
+ at += header.len;
+ y = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const grapheme = surface.at(x, y).grapheme();
+ if (grapheme.len > 0) {
+ @memcpy(frame[at..][0..grapheme.len], grapheme);
+ at += grapheme.len;
+ } else {
+ frame[at] = ' ';
+ at += 1;
+ }
}
- try buf.append(gpa, '\n');
+ frame[at] = '\n';
+ at += 1;
}
- try buf.appendSlice(gpa, "---ENDFRAME---\n");
- writeFd(1, buf.items);
+ @memcpy(frame[at..][0..footer.len], footer);
+ at += footer.len;
+ std.debug.assert(at == frame.len);
+ writeFd(1, frame);
}
// =====================================================================
@@ -2553,7 +2725,7 @@ fn drainEffects(
io: std.Io,
gpa: std.mem.Allocator,
queue: *Queue,
- pipe_tasks: *std.ArrayList(PipeTask),
+ pipe_tasks: *PipeTasks,
g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
inotify_fd: c_int,
watches: *[pardes.MAX_PANES]?Watch,
@@ -2640,7 +2812,7 @@ fn drainEffects(
defer gpa.free(z);
_ = c.SDL_SetClipboardText(z.ptr);
},
- .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e),
+ .lsp => |e| if (threads_ok) spawnLsp(core, queue, e),
.pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e),
.watch => |w| {
// starting, the path and the on-disk bytes are read off the core
@@ -2702,15 +2874,30 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void {
/// window.open in a tiny generated script; single quotes and backslashes are
/// %-escaped so the url can't break out of the JS string literal.
fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void {
- var safe: std.ArrayList(u8) = .empty;
- defer safe.deinit(gpa);
+ const prefix = "window.open('";
+ const suffix = "','_blank')";
+ var escaped_len: usize = 0;
+ for (url) |ch| escaped_len = std.math.add(usize, escaped_len, if (ch == '\'' or ch == '\\') 3 else 1) catch return;
+ const script_len = std.math.add(usize, prefix.len + suffix.len, escaped_len) catch return;
+ const script = gpa.allocSentinel(u8, script_len, 0) catch return;
+ defer gpa.free(script);
+ @memcpy(script[0..prefix.len], prefix);
+ var at = prefix.len;
for (url) |ch| switch (ch) {
- '\'' => safe.appendSlice(gpa, "%27") catch return,
- '\\' => safe.appendSlice(gpa, "%5C") catch return,
- else => safe.append(gpa, ch) catch return,
+ '\'' => {
+ @memcpy(script[at..][0..3], "%27");
+ at += 3;
+ },
+ '\\' => {
+ @memcpy(script[at..][0..3], "%5C");
+ at += 3;
+ },
+ else => {
+ script[at] = ch;
+ at += 1;
+ },
};
- const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return;
- defer gpa.free(script);
+ @memcpy(script[at..][0..suffix.len], suffix);
std.os.emscripten.emscripten_run_script(script.ptr);
}
@@ -3125,13 +3312,19 @@ fn prepareNativeImages(
uploadNativeTexture(g, gpa, cmd, place) catch continue;
}
- var stale: std.ArrayList(pardes.ImageCacheKey) = .empty;
- defer stale.deinit(gpa);
- var iterator = g.native_images.iterator();
- while (iterator.next()) |entry|
- if (!surfaceHasNativeKey(surface, entry.key_ptr.*))
- stale.append(gpa, entry.key_ptr.*) catch {};
- for (stale.items) |key| releaseNativeImage(g, key);
+ while (true) {
+ var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined;
+ var stale_len: usize = 0;
+ var iterator = g.native_images.iterator();
+ while (iterator.next()) |entry| {
+ if (surfaceHasNativeKey(surface, entry.key_ptr.*)) continue;
+ stale[stale_len] = entry.key_ptr.*;
+ stale_len += 1;
+ if (stale_len == stale.len) break;
+ }
+ for (stale[0..stale_len]) |key| releaseNativeImage(g, key);
+ if (stale_len < stale.len) break;
+ }
var count: u32 = 0;
for (surface.images[0..surface.nimages]) |maybe| {
diff --git a/src/look.zig b/src/look.zig
index 273e20c3..7169cf8b 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -340,6 +340,11 @@ const platform_has_fs = switch (pardes.platform) {
const find_max_hits = 512;
const find_max_depth = 16;
const find_max_steps = 100_000;
+/// One search result buffer. A grep can visit one root per pane, each root can
+/// contribute `find_max_hits`, and native paths are capped at 4096 bytes below.
+/// Callers allocate this conservative ceiling once; a full buffer truncates at
+/// the last complete row.
+pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320);
/// Directories a source tree has no answers in, skipped whole. fd reads
/// .gitignore for this; pardes has no ignore parser, and every one of these
@@ -359,50 +364,61 @@ const find_skip = [_][]const u8{
/// resolves against the same directory the walk started in and reads as the
/// short name the searcher was looking for. Only real directories are
/// entered, so a symlink can never close a cycle.
-pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void {
- var hits: std.ArrayList([]const u8) = .empty;
+/// Filesystem setup and traversal errors are returned to the UI boundary.
+pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize {
+ var hits: [find_max_hits][]const u8 = undefined;
+ var hits_len: usize = 0;
if (platform_has_fs) {
// Zig 0.16 moved the filesystem behind std.Io; the blocking
// single-threaded implementation (the one std.debug itself holds) IS
// the synchronous walk a sans-IO core wants — no pool, no cancelation.
const io = std.Io.Threaded.global_single_threaded.io();
- var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return;
+ var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true });
defer root.close(io);
// walkSelectively, not walk: descending is opt-in, which is the only
// way to express the depth cap and find_skip at all.
- var w = root.walkSelectively(arena) catch return;
+ var w = try root.walkSelectively(arena);
defer w.deinit();
var steps: usize = 0;
- walk: while (steps < find_max_steps and hits.items.len < find_max_hits) {
+ walk: while (steps < find_max_steps and hits_len < hits.len) {
steps += 1; // an unreadable dir burns a step too, so it cannot spin
- const e = (w.next(io) catch continue) orelse break;
- if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null)
+ const e = (try w.next(io)) orelse break;
+ if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) {
// e.path points into the walker's own buffer, dead at next()
- hits.append(arena, arena.dupe(u8, e.path) catch break) catch break;
+ hits[hits_len] = try arena.dupe(u8, e.path);
+ hits_len += 1;
+ }
if (e.kind != .directory or e.depth() >= find_max_depth) continue;
for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk;
- w.enter(io, e) catch {};
+ try w.enter(io, e);
}
} else {
// web: the build-generated source archive IS the filesystem, and it is
// already a flat list of paths — the whole walk is the match.
for (embedded_sources.all) |s| {
- if (hits.items.len >= find_max_hits) break;
- if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null)
- hits.append(arena, s.path) catch break;
+ if (hits_len >= hits.len) break;
+ if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) {
+ hits[hits_len] = s.path;
+ hits_len += 1;
+ }
}
}
// readdir order is undefined; sort so the same tree gives the same buffer
// twice running and n/N walks it in a sane order.
- std.mem.sort([]const u8, hits.items, {}, struct {
+ std.mem.sort([]const u8, hits[0..hits_len], {}, struct {
fn lt(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lt);
- for (hits.items) |h| {
- out.appendSlice(arena, h) catch return;
- out.append(arena, '\n') catch return;
+ var written: usize = 0;
+ for (hits[0..hits_len]) |h| {
+ if (h.len + 1 > out.len - written) break;
+ @memcpy(out[written..][0..h.len], h);
+ written += h.len;
+ out[written] = '\n';
+ written += 1;
}
+ return written;
}
/// how much of one file Grep reads. The core is synchronous, so a tree with a
@@ -417,13 +433,15 @@ const grep_max_files = 20_000;
/// MATCH's span and not just its first cell, so stepping onto one selects the
/// text that matched (config.range_sep). Returns the rows written, at most
/// `budget`.
-fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: []const u8, out: *std.ArrayList(u8), budget: usize) usize {
- var n: usize = 0;
+const GrepResult = struct { bytes: usize, hits: usize };
+
+fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult {
+ var result: GrepResult = .{ .bytes = 0, .hits = 0 };
var line: usize = 0;
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |raw| {
line += 1;
- if (n >= budget) break;
+ if (result.hits >= budget) break;
const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue;
// one minified line can be the whole file: cut it, but never mid
// codepoint — a partial UTF-8 sequence reaches the renderer as a hit
@@ -431,13 +449,13 @@ fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: [
const ln = std.mem.trimEnd(u8, raw, " \t\r");
var cut = @min(ln.len, 200);
while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1;
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
+ const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{
path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut],
}) catch break;
- out.appendSlice(arena, row) catch break;
- n += 1;
+ result.bytes += row.len;
+ result.hits += 1;
}
- return n;
+ return result;
}
/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat`
@@ -449,65 +467,69 @@ fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: [
/// path, which resolves from anywhere. Same walk, same skip list and same three
/// caps as find(), plus grep_max_bytes and a NUL sniff so a binary never lands
/// in the results.
-pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void {
+/// Filesystem setup, traversal, and read errors are returned to the UI boundary.
+pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize {
var hits: usize = 0;
+ var written: usize = 0;
if (!platform_has_fs) {
// web: the build-generated source archive IS the filesystem
for (embedded_sources.all) |s| {
- if (hits >= find_max_hits) break;
- hits += grepText(arena, s.path, s.contents, pat, out, find_max_hits - hits);
+ if (hits >= find_max_hits or written == out.len) break;
+ const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits);
+ hits += result.hits;
+ written += result.bytes;
}
- return;
+ return written;
}
const root_path = std.mem.trimEnd(u8, dir, "/");
const home = std.mem.trimEnd(u8, base, "/");
- // the walk COLLECTS, then the read scans in sorted order: readdir order is
- // undefined, and rows the same tree hands back in a different order twice
- // running are rows n/N cannot be trusted to walk (find() sorts for the
- // same reason). e.path dies at the next next(), so these are copies.
- var files: std.ArrayList([]const u8) = .empty;
+ // The walk collects into one bounded allocation, then the read scans in
+ // sorted order. e.path dies at the next next(), so these are copies.
+ const files = try arena.alloc([]const u8, grep_max_files);
+ var files_len: usize = 0;
{
const io = std.Io.Threaded.global_single_threaded.io();
- var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return;
+ var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true });
defer root.close(io);
- var w = root.walkSelectively(arena) catch return;
+ var w = try root.walkSelectively(arena);
defer w.deinit();
var steps: usize = 0;
- walk: while (steps < find_max_steps and files.items.len < grep_max_files) {
+ walk: while (steps < find_max_steps and files_len < files.len) {
steps += 1;
- const e = (w.next(io) catch continue) orelse break;
+ const e = (try w.next(io)) orelse break;
if (e.kind == .directory) {
if (e.depth() >= find_max_depth) continue;
for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk;
- w.enter(io, e) catch {};
+ try w.enter(io, e);
continue;
}
if (e.kind != .file) continue;
- const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }) catch break;
- files.append(arena, path) catch break;
+ files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path });
+ files_len += 1;
}
}
- std.mem.sort([]const u8, files.items, {}, struct {
+ std.mem.sort([]const u8, files[0..files_len], {}, struct {
fn lt(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lt);
- // ONE buffer for every file: readFile is unbounded, and a synchronous
- // search must not swallow a file it cannot afford to hold
- const buf = gpa.alloc(u8, grep_max_bytes) catch return;
+ // ONE bounded buffer reused for every file: a synchronous search must not
+ // swallow a file it cannot afford to hold.
+ const buf = try gpa.alloc(u8, grep_max_bytes);
defer gpa.free(buf);
- for (files.items) |path| {
- if (hits >= find_max_hits) break;
+ for (files[0..files_len]) |path| {
+ if (hits >= find_max_hits or written == out.len) break;
var pathbuf: [4096]u8 = undefined;
- const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch continue;
+ const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
- if (fd < 0) continue;
+ if (fd < 0) return error.OpenFailed;
var len: usize = 0;
while (len < buf.len) {
const n = libc.read(fd, buf[len..].ptr, buf.len - len);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
- break;
+ _ = libc.close(fd);
+ return error.ReadFailed;
}
if (n == 0) break;
len += @intCast(n);
@@ -522,8 +544,11 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b
path[home.len + 1 ..]
else
path;
- hits += grepText(arena, shown, text, pat, out, find_max_hits - hits);
+ const result = grepText(shown, text, pat, out[written..], find_max_hits - hits);
+ hits += result.hits;
+ written += result.bytes;
}
+ return written;
}
/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed)
@@ -536,11 +561,18 @@ fn isDir(path: [*:0]const u8) bool {
/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web
/// reads from the generated source archive; native shells read the real fs.
+const read_file_max_bytes = 256 * 1024 * 1024;
+const read_stream_max_bytes = 4 * 1024 * 1024;
+
+/// Read a whole file with one size-bounded allocation. A file that grows after
+/// fstat is read as the snapshot size; zero-size virtual files get a separate
+/// bounded stream read. Files over either applicable cap are rejected.
pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
if (!platform_has_fs) {
var normalized_buf: [4096]u8 = undefined;
const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed;
const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed;
+ if (source.contents.len > read_file_max_bytes) return error.FileTooLarge;
return gpa.dupe(u8, source.contents);
}
var pathbuf: [4096]u8 = undefined;
@@ -548,19 +580,66 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- errdefer buf.deinit(gpa);
- var chunk: [16384]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ const size: usize = if (end < 0) 0 else @intCast(end);
+ if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
+ if (size == 0) {
+ // procfs and similar virtual files report zero size. Probe once so a
+ // genuinely empty file remains an exact zero-byte allocation, then use
+ // one conservative bounded allocation for a non-empty stream.
+ var first: [16 * 1024]u8 = undefined;
+ var first_len: usize = 0;
+ while (true) {
+ const n = libc.read(fd, &first, first.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ first_len = @intCast(n);
+ break;
+ }
+ if (first_len == 0) return gpa.alloc(u8, 0);
+ var stream = try gpa.alloc(u8, read_stream_max_bytes);
+ errdefer gpa.free(stream);
+ @memcpy(stream[0..first_len], first[0..first_len]);
+ var stream_len = first_len;
+ while (stream_len < stream.len) {
+ const n = libc.read(fd, stream[stream_len..].ptr, stream.len - stream_len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ stream_len += @intCast(n);
+ }
+ if (stream_len == stream.len) {
+ var extra: [1]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &extra, 1);
+ if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n < 0) return error.ReadFailed;
+ if (n > 0) return error.FileTooLarge;
+ break;
+ }
+ }
+ if (stream_len != stream.len) stream = try gpa.realloc(stream, stream_len);
+ return stream;
+ }
+ if (size > read_file_max_bytes) return error.FileTooLarge;
+ var buf = try gpa.alloc(u8, size);
+ errdefer gpa.free(buf);
+ var len: usize = 0;
+ while (len < buf.len) {
+ const n = libc.read(fd, buf[len..].ptr, buf.len - len);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
if (n == 0) break;
- try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ len += @intCast(n);
}
- return buf.toOwnedSlice(gpa);
+ if (len != buf.len) buf = try gpa.realloc(buf, len);
+ return buf;
}
// ---- shell cwd: what directory a pane's looks resolve against ----
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index e1624df0..76c5594c 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -60,6 +60,9 @@ const enc: offsets.Encoding = .@"utf-8";
/// 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,
@@ -171,12 +174,18 @@ fn nowUs() u64 {
/// find out what actually happened.
const Trace = struct {
on: bool = false,
- arena: std.mem.Allocator,
- buf: std.ArrayList(u8) = .empty,
+ buf: [max_trace_bytes]u8 = undefined,
+ len: usize = 0,
fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
- if (!t.on) return;
- t.buf.print(t.arena, fmt ++ "\n", args) catch {};
+ 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];
}
};
@@ -189,33 +198,34 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out
// 24-entry ring every time you looked at it.
if (req.kind == .status) return status(arena, req, out) catch {};
- // Rows land in a scratch buffer first, for two reasons a plain Writer
- // cannot serve: the log wants an exact row count, and `explain` throws the
- // rows away and prints the narration in their place — neither a count nor
- // a rewind exists on a Writer you do not own.
- var scratch: std.Io.Writer.Allocating = .init(gpa);
- defer scratch.deinit();
+ // Rows land in bounded scratch storage first, for two reasons a plain
+ // Writer cannot serve: the log wants an exact row count, and `explain`
+ // throws the rows away and prints the narration in their place. A query
+ // that exceeds the row budget's byte allowance keeps its ordered prefix.
+ const scratch_buf = gpa.alloc(u8, max_output_bytes) catch return;
+ defer gpa.free(scratch_buf);
+ var scratch: std.Io.Writer = .fixed(scratch_buf);
- var tr: Trace = .{ .arena = arena, .on = req.kind == .explain };
+ var tr: Trace = .{ .on = req.kind == .explain };
const t0 = nowUs();
var err_name: []const u8 = "";
- run(gpa, arena, req, &scratch.writer, &tr) catch |e| {
+ run(gpa, arena, req, &scratch, &tr) catch |e| {
err_name = @errorName(e);
tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name});
};
const us = nowUs() -| t0;
- const rows = std.mem.count(u8, scratch.written(), "\n");
+ const rows = std.mem.count(u8, scratch.buffered(), "\n");
record(req, us, rows, err_name);
if (req.kind != .explain) {
- out.writeAll(scratch.written()) catch {};
+ out.writeAll(scratch.buffered()) catch {};
return;
}
// the question was never "where is it", it was "what did you do"
out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
}) catch {};
- out.writeAll(tr.buf.items) catch {};
+ out.writeAll(tr.written()) catch {};
if (hideTime())
out.print("\n{d} row(s)\n", .{rows}) catch {}
else
@@ -470,10 +480,16 @@ const Lines = struct {
src: []const u8,
fn build(arena: std.mem.Allocator, src: []const u8) !Lines {
- var starts: std.ArrayList(u32) = .empty;
- try starts.append(arena, 0);
- for (src, 0..) |c, i| if (c == '\n') try starts.append(arena, @intCast(i + 1));
- return .{ .starts = starts.items, .src = src };
+ 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 {
@@ -529,7 +545,8 @@ fn goto(
const ctx = try Analyser.getPositionContext(arena, tree, off, true);
tr.note("position context: .{s}", .{@tagName(ctx)});
- var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
+ 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 {
@@ -550,7 +567,8 @@ fn goto(
tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name});
return;
};
- try decls.append(arena, d);
+ decls[decl_count] = d;
+ decl_count += 1;
},
.field_access => |loc| {
const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
@@ -576,19 +594,22 @@ fn goto(
explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
return;
}
- try decls.appendSlice(arena, found);
+ 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;
- try decls.append(arena, d);
+ 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;
- try decls.append(arena, d);
+ 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.
@@ -626,10 +647,10 @@ fn goto(
},
}
- tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) });
+ 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.items) |d| {
+ 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),
@@ -730,6 +751,86 @@ fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]cons
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
@@ -796,7 +897,7 @@ fn completion(
try analyser.resolveDerefType(ty) orelse
try analyser.resolveOptionalUnwrap(ty) orelse break;
}
- // `instance_access` is collectDeclarationsOfContainer's one knob and it
+ // `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
@@ -806,12 +907,13 @@ fn completion(
// whose fields are also its tags, so it wants the instance side wherever
// it appears.
const value_pos = ctx == .enum_literal and !in_init;
- var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
- try analyser.collectDeclarationsOfContainer(ty, handle, switch (ctx) {
+ 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);
- tr.note("{d} candidate(s) in scope", .{decls.items.len});
+ }, 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
@@ -824,7 +926,7 @@ fn completion(
const pad = tree.source.len - orig.len;
const dot = lsp.lineCol(orig, off);
var n: usize = 0;
- for (decls.items) |d| {
+ 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
@@ -959,10 +1061,9 @@ fn workspaceSymbols(
out: *std.Io.Writer,
) !void {
if (req.arg.len == 0) return;
- var files: std.ArrayList([]const u8) = .empty;
- try collectZigFiles(arena, io, req.root, &files);
+ const files = try collectZigFiles(arena, io, req.root);
var n: usize = 0;
- for (files.items) |path| {
+ 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;
@@ -1147,14 +1248,18 @@ fn treeDiagnostics(
const lines: Lines = try .build(arena, tree.source);
for (tree.errors) |e| {
if (n >= max_rows) return n;
- var buf: std.ArrayList(u8) = .empty;
- var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf);
- tree.renderError(e, &w.writer) catch continue;
+ 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;
lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{
- w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"),
+ w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"),
}));
}
return n;
@@ -1187,17 +1292,16 @@ fn workspaceDiagnostics(
req: lsp.Req,
out: *std.Io.Writer,
) !void {
- var files: std.ArrayList([]const u8) = .empty;
- try collectZigFiles(arena, io, req.root, &files);
+ const files = try collectZigFiles(arena, io, req.root);
var total: usize = 0;
- for (files.items) |path| {
+ 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 += treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out) catch continue;
if (total >= max_rows) return;
}
- if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len}));
+ if (total == 0) 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
@@ -1225,8 +1329,12 @@ fn codeActions(
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 = arena,
+ .arena = action_fba.allocator(),
.analyser = analyser,
.handle = handle,
.offset_encoding = enc,
@@ -1236,7 +1344,7 @@ fn codeActions(
const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc);
builder.generateCodeActionsInRange(at) catch {};
- for (builder.actions.items) |a| {
+ for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| {
out.print("{s}\n", .{a.title}) catch return;
}
if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {};
@@ -1257,10 +1365,14 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi
lsp.row(out, path, 0, 0, "cannot format: file does not parse");
return;
}
- var buf: std.ArrayList(u8) = .empty;
- var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf);
- tree.render(arena, &w.writer, .{}) catch return;
- const formatted = w.written();
+ var count_buf: [4096]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&count_buf);
+ tree.render(arena, &counting.writer, .{}) catch return;
+ 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);
+ tree.render(arena, &w, .{}) catch return;
+ const formatted = w.buffered();
if (std.mem.eql(u8, formatted, req.source)) {
lsp.row(out, path, 0, 0, "already formatted");
return;
@@ -1287,13 +1399,15 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi
/// 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, out: *std.ArrayList([]const u8)) !void {
- if (root.len == 0) return;
- var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return;
+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;
+ var w = dir.walkSelectively(arena) catch return files[0..0];
defer w.deinit();
- while (out.items.len < max_files) {
+ while (len < files.len) {
const e = (w.next(io) catch break) orelse break;
switch (e.kind) {
.directory => {
@@ -1304,12 +1418,13 @@ fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out:
},
.file => {
if (!std.mem.endsWith(u8, e.basename, ".zig")) continue;
- const p = std.fs.path.join(arena, &.{ root, e.path }) catch continue;
- try out.append(arena, p);
+ 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 {
diff --git a/src/macos.zig b/src/macos.zig
index 2e628d54..d463b842 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -117,32 +117,29 @@ const Msg = union(enum) {
}
};
-/// 0.16 has no std.Thread.Mutex, and the gui shell's queue already settled
-/// this: spin on the lock-free std.atomic.Mutex, and keep the critical section
-/// to a pointer append. The reader allocates its chunk BEFORE taking the lock
-/// for exactly that reason — a screenful of output must not make a keystroke
-/// spin through a 64 KiB copy.
-///
-/// ponytail: unbounded. `yes` in a pane can enqueue faster than the host
-/// drains, and nothing throttles the reader — the tty shell gets that for free
-/// from vaxis's 512-slot queue, whose post blocks when full, and the SDL shell
-/// has the same hole this does. Bound it on queued bytes the day someone
-/// watches RSS climb; the trap to avoid is a wait that teardown cannot cancel.
+const inbox_capacity = 512;
+
+const MessageBatch = struct {
+ items: [inbox_capacity]Msg = undefined,
+ len: usize = 0,
+
+ fn slice(batch: *MessageBatch) []Msg {
+ return batch.items[0..batch.len];
+ }
+};
+
const Inbox = struct {
mutex: std.atomic.Mutex = .unlocked,
- items: std.ArrayList(Msg) = .empty,
+ items: [inbox_capacity]Msg = undefined,
+ head: usize = 0,
+ len: usize = 0,
+ closed: bool = false,
/// Set when a wakeup has been delivered and not yet answered by a tick.
- /// Without it a busy shell posts one wakeup per 64 KiB chunk, and each one
- /// is a block on the host's main queue — a screenful of output becomes
- /// thousands of scheduled pumps that all find the same drained inbox.
wake_pending: std.atomic.Value(bool) = .init(false),
fn lock(q: *Inbox) void {
- // Bounded, unlike the gui shell's otherwise identical spin. AppKit's
- // main thread runs at a higher QoS than these reader tasks and a raw
- // CAS spin donates no priority, so a reader preempted inside the append
- // (which can realloc) would have the highest-priority thread in the
- // process spinning on it. Yielding hands the core back.
+ // AppKit's main thread runs at a higher QoS than reader tasks, so yield
+ // periodically rather than donating a full core to a preempted reader.
var spins: u8 = 0;
while (!q.mutex.tryLock()) {
spins +%= 1;
@@ -150,10 +147,70 @@ const Inbox = struct {
}
}
+ fn removeAt(q: *Inbox, offset: usize) Msg {
+ const removed = q.items[(q.head + offset) % q.items.len];
+ var i = offset;
+ while (i + 1 < q.len) : (i += 1)
+ q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len];
+ q.len -= 1;
+ return removed;
+ }
+
+ /// Pty output is lossy under sustained backpressure. EOF is structural:
+ /// admit it by evicting queued output so dead readers are always reaped.
fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void {
q.lock();
defer q.mutex.unlock();
- q.items.append(gpa, m) catch m.free(gpa);
+ if (q.closed) {
+ m.free(gpa);
+ return;
+ }
+ if (q.len == q.items.len) {
+ const incoming_eof = switch (m) {
+ .eof => true,
+ .output => false,
+ };
+ if (!incoming_eof) {
+ m.free(gpa);
+ return;
+ }
+ var offset: usize = 0;
+ while (offset < q.len) : (offset += 1)
+ if (switch (q.items[(q.head + offset) % q.items.len]) {
+ .output => true,
+ .eof => false,
+ }) break;
+ if (offset == q.len) return;
+ q.removeAt(offset).free(gpa);
+ }
+ q.items[(q.head + q.len) % q.items.len] = m;
+ q.len += 1;
+ }
+
+ fn take(q: *Inbox) MessageBatch {
+ q.lock();
+ defer q.mutex.unlock();
+ var batch: MessageBatch = .{};
+ while (q.len > 0) {
+ batch.items[batch.len] = q.items[q.head];
+ batch.len += 1;
+ q.head = (q.head + 1) % q.items.len;
+ q.len -= 1;
+ }
+ q.head = 0;
+ return batch;
+ }
+
+ fn close(q: *Inbox, gpa: std.mem.Allocator) void {
+ q.lock();
+ defer q.mutex.unlock();
+ q.closed = true;
+ while (q.len > 0) {
+ q.items[q.head].free(gpa);
+ q.head = (q.head + 1) % q.items.len;
+ q.len -= 1;
+ }
+ q.head = 0;
}
};
@@ -164,7 +221,8 @@ const State = struct {
core: *pardes.Pardes,
arena: std.heap.ArenaAllocator,
runtime: Runtime,
- cells: std.ArrayList(Cell) = .empty,
+ cells: []Cell = &.{},
+ frame_len: usize = 0,
/// The grid `cells` actually holds. Not read back off the core: a render
/// can move screen_w/screen_h and then fail, and a host that sized its
/// loops from those would walk off the buffer.
@@ -180,9 +238,6 @@ const State = struct {
/// moves a whole row at a time, so fractional trackpad travel accumulates
/// here and is spent as wheel presses — see pardes_scroll.
scroll_lag: f32 = 0,
- /// Swapped with the inbox under the lock, so draining it costs one pointer
- /// exchange and neither side reallocates once capacities have settled.
- tick_msgs: std.ArrayList(Msg) = .empty,
/// Owns the bytes of the user config, which Options only borrows.
config_arena: std.heap.ArenaAllocator,
};
@@ -209,6 +264,9 @@ export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_
fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
const gpa = std.heap.smp_allocator;
+ const allocs = pardes.allocators.init(gpa);
+ errdefer pardes.allocators.deinit();
+
const threaded = try gpa.create(std.Io.Threaded);
errdefer gpa.destroy(threaded);
threaded.* = .init(gpa, .{});
@@ -218,7 +276,12 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
var config_arena: std.heap.ArenaAllocator = .init(gpa);
errdefer config_arena.deinit();
- var opts: pardes.Options = .{ .tty_only = true };
+ var opts: pardes.Options = .{
+ .tty_only = true,
+ .image_allocator = allocs.image,
+ .pdf_allocator = allocs.pdf,
+ .tree_sitter_allocator = allocs.tree_sitter,
+ };
// Native shells opt into the user config, and every builtin in it must have
// run before the host can render a frame — so it is read here, before
// Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from
@@ -226,11 +289,14 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
if (captureEnv(config_arena.allocator())) |*env|
opts.startup_config = user_config.load(io, config_arena.allocator(), env);
- // stb_image's allocator shim, for image panes.
- pardes.image.start(io, gpa);
+ pardes.image.start(io, allocs.image);
errdefer pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.start(allocs.tree_sitter);
+ errdefer pardes.syntax.stop();
- const core = try pardes.Pardes.init(gpa, opts);
+ const core = try pardes.Pardes.init(allocs.pardes, opts);
errdefer core.deinit();
// Shells emit OSC 133 prompt marks through these, which is what makes
@@ -246,7 +312,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
.threaded = threaded,
.io = io,
.core = core,
- .arena = .init(gpa),
+ .arena = .init(allocs.frame),
.config_arena = config_arena,
.runtime = if (runtime) |r| r.* else .{},
};
@@ -279,17 +345,17 @@ export fn pardes_deinit() void {
for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane));
// Only now is the inbox quiet. Anything still queued owns gpa bytes and
// would show up as a leak rather than as the shutdown it actually is.
- for (st.inbox.items.items) |msg| msg.free(st.gpa);
- st.inbox.items.deinit(st.gpa);
- for (st.tick_msgs.items) |msg| msg.free(st.gpa);
- st.tick_msgs.deinit(st.gpa);
- st.cells.deinit(st.gpa);
- st.core.deinit();
+ st.inbox.close(st.gpa);
+ if (st.cells.len > 0) st.gpa.free(st.cells);
st.arena.deinit();
- st.config_arena.deinit();
+ st.core.deinit();
pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ st.config_arena.deinit();
st.threaded.deinit();
st.gpa.destroy(st.threaded);
+ pardes.allocators.deinit();
state = null;
}
@@ -308,15 +374,12 @@ export fn pardes_animating() bool {
/// wakeup does not cost the host a repaint.
export fn pardes_tick() bool {
const st = &(state orelse return false);
- // Cleared BEFORE the swap: a reader that pushes while this drain is running
- // must be able to schedule the tick that will collect it.
+ // Cleared before the drain: a reader that pushes during this tick must be
+ // able to schedule the next one.
st.inbox.wake_pending.store(false, .release);
- st.inbox.lock();
- std.mem.swap(std.ArrayList(Msg), &st.inbox.items, &st.tick_msgs);
- st.inbox.mutex.unlock();
-
- var changed = st.tick_msgs.items.len > 0;
- for (st.tick_msgs.items) |msg| {
+ var batch = st.inbox.take();
+ var changed = batch.len > 0;
+ for (batch.slice()) |msg| {
defer msg.free(st.gpa);
switch (msg) {
.output => |o| {
@@ -327,16 +390,12 @@ export fn pardes_tick() bool {
if (st.gens[e.pane] != e.gen) continue;
// The shell is gone: join its reader (a completed future that
// is never awaited leaks its allocation), close the master and
- // free the slot. Without this the slot is only ever reaped by a
- // later spawn INTO it — and the core emits spawn from newPane
- // alone, so a pane that becomes a file pane instead would hold
- // the dead fd for the life of the app.
+ // free the slot.
reap(st, e.pane);
st.core.update(.{ .eof = .{ .pane = e.pane } });
},
}
}
- st.tick_msgs.clearRetainingCapacity();
if (drainEffects(st, true)) changed = true;
// A live theme transition repaints on its own clock; say so, or the host
// stops ticking and the fade freezes half-applied.
@@ -435,7 +494,7 @@ export fn pardes_frame() u32 {
// The three accessors below must never describe a different frame than the
// count this returns, so a failure empties all of them together rather than
// leaving last frame's buffer behind a fresh cols/rows.
- st.cells.clearRetainingCapacity();
+ st.frame_len = 0;
st.frame_cols = 0;
st.frame_rows = 0;
const surface = st.core.render(st.arena.allocator()) catch |err| {
@@ -443,10 +502,22 @@ export fn pardes_frame() u32 {
return 0;
};
const count: usize = @as(usize, surface.cols) * surface.rows;
- st.cells.resize(st.gpa, count) catch return 0;
+ if (count != st.cells.len) {
+ if (count == 0) {
+ if (st.cells.len > 0) st.gpa.free(st.cells);
+ st.cells = &.{};
+ } else {
+ const resized = if (st.cells.len == 0)
+ st.gpa.alloc(Cell, count)
+ else
+ st.gpa.realloc(st.cells, count);
+ st.cells = resized catch return 0;
+ }
+ }
+ st.frame_len = count;
st.frame_cols = surface.cols;
st.frame_rows = surface.rows;
- for (surface.cells, st.cells.items) |cell, *out| {
+ for (surface.cells, st.cells[0..count]) |cell, *out| {
out.* = .{
.text = @splat(0),
.fg = encodeColor(cell.style.fg),
@@ -462,7 +533,7 @@ export fn pardes_frame() u32 {
export fn pardes_frame_cells() ?[*]const Cell {
const st = &(state orelse return null);
- return if (st.cells.items.len == 0) null else st.cells.items.ptr;
+ return if (st.frame_len == 0) null else st.cells.ptr;
}
export fn pardes_frame_cols() u16 {
diff --git a/src/main.zig b/src/main.zig
index 472dda6e..607b0136 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -237,6 +237,7 @@ fn parseCtrlKey(raw: []const u8) ?u21 {
// builtins.zig and still needs its own line here.
test {
_ = @import("user_config.zig");
+ _ = @import("allocators.zig");
if (comptime pardes.platform == .gui) {
_ = @import("gui/gui.zig");
_ = @import("gui/fonts.zig");
diff --git a/src/modal.zig b/src/modal.zig
index 8e36d1ae..424421f6 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -453,6 +453,13 @@ pub fn lineSlice(content: []const u8, row: usize) []const u8 {
}
// ---- file content mutations. caller frees the returned slice + the old one. ----
+fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: usize, replacement: []const u8) ![]u8 {
+ const out = try alloc.alloc(u8, content.len - (end - start) + replacement.len);
+ @memcpy(out[0..start], content[0..start]);
+ @memcpy(out[start..][0..replacement.len], replacement);
+ @memcpy(out[start + replacement.len ..], content[end..]);
+ return out;
+}
// insert `text` at (row, col). col is clamped to the line length.
pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 {
@@ -460,11 +467,7 @@ pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text:
const line = lineSlice(content, row);
const col = @min(c.col, line.len);
const off = lineStartOffset(content, row) + col;
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..off]);
- try out.appendSlice(alloc, text);
- try out.appendSlice(alloc, content[off..]);
- return out.toOwnedSlice(alloc);
+ return spliceAlloc(alloc, content, off, off, text);
}
// delete the character at (row, col). no-op if col is past the line end.
@@ -472,10 +475,7 @@ pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u
const line = lineSlice(content, c.row);
if (c.col >= line.len) return alloc.dupe(u8, content);
const off = lineStartOffset(content, c.row) + c.col;
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..off]);
- try out.appendSlice(alloc, content[off + 1 ..]);
- return out.toOwnedSlice(alloc);
+ return spliceAlloc(alloc, content, off, off + 1, "");
}
// delete whole lines [r0, r1] inclusive (the line content + their terminators).
@@ -486,7 +486,11 @@ pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1:
const n = lineCount(content);
const lo = @min(r0, r1);
const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1);
- if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") };
+ if (n == 0 or hi < lo) {
+ const unchanged = try alloc.dupe(u8, content);
+ errdefer alloc.free(unchanged);
+ return .{ .content = unchanged, .deleted = try alloc.dupe(u8, "") };
+ }
const start = lineStartOffset(content, lo);
// end = start of line (hi+1), or content.len if hi is the last line.
@@ -497,18 +501,23 @@ pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1:
const cut_hi = end;
if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line
- var deleted: std.ArrayList(u8) = .empty;
- {
- var r = lo;
- while (r <= hi) : (r += 1) {
- if (r > lo) try deleted.append(alloc, '\n');
- try deleted.appendSlice(alloc, lineSlice(content, r));
+ var deleted_len: usize = hi - lo;
+ var r = lo;
+ while (r <= hi) : (r += 1) deleted_len += lineSlice(content, r).len;
+ const deleted = try alloc.alloc(u8, deleted_len);
+ errdefer alloc.free(deleted);
+ var write: usize = 0;
+ r = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) {
+ deleted[write] = '\n';
+ write += 1;
}
+ const line = lineSlice(content, r);
+ @memcpy(deleted[write..][0..line.len], line);
+ write += line.len;
}
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..cut_lo]);
- try out.appendSlice(alloc, content[cut_hi..]);
- return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) };
+ return .{ .content = try spliceAlloc(alloc, content, cut_lo, cut_hi, ""), .deleted = deleted };
}
// normalize two cursors into document order (lo <= hi), then byte offsets of the
@@ -537,40 +546,39 @@ pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b:
const r = rangeBytes(content, a, b);
const deleted = try alloc.dupe(u8, content[r.s..r.e]);
errdefer alloc.free(deleted);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..r.s]);
- try out.appendSlice(alloc, content[r.e..]);
- return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted };
+ return .{ .content = try spliceAlloc(alloc, content, r.s, r.e, ""), .deleted = deleted };
}
// replace line `row`'s text with "" (keep the line, empty it). For `c`hange line.
pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 {
const line = lineSlice(content, row);
const start = lineStartOffset(content, row);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..start]);
- try out.appendSlice(alloc, content[start + line.len ..]);
- return out.toOwnedSlice(alloc);
+ return spliceAlloc(alloc, content, start, start + line.len, "");
}
// paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines.
pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 {
const n = lineCount(content);
const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len;
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..off]);
- if (off < content.len or (content.len > 0 and content[content.len - 1] == '\n')) {
- // mid-buffer, or below the last line of newline-terminated content:
- // the pasted block carries its own terminator
- try out.appendSlice(alloc, text);
- try out.append(alloc, '\n');
- } else {
- // below a terminator-less last line: open it with a newline instead
- if (off > 0) try out.append(alloc, '\n');
- try out.appendSlice(alloc, text);
+ const suffix_newline = off < content.len or (content.len > 0 and content[content.len - 1] == '\n');
+ const prefix_newline = !suffix_newline and off > 0;
+ const extra: usize = @intFromBool(suffix_newline or prefix_newline);
+ const out = try alloc.alloc(u8, content.len + text.len + extra);
+ var write: usize = 0;
+ @memcpy(out[write..][0..off], content[0..off]);
+ write += off;
+ if (prefix_newline) {
+ out[write] = '\n';
+ write += 1;
+ }
+ @memcpy(out[write..][0..text.len], text);
+ write += text.len;
+ if (suffix_newline) {
+ out[write] = '\n';
+ write += 1;
}
- try out.appendSlice(alloc, content[off..]);
- return out.toOwnedSlice(alloc);
+ @memcpy(out[write..], content[off..]);
+ return out;
}
// the cursor position AFTER `text` inserted at `c` (one past its last char).
@@ -589,11 +597,7 @@ pub fn advanceBy(c: Cursor, text: []const u8) Cursor {
// replace the inclusive char range [a, b] with `text` (R replace-with-yank).
pub fn replaceRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, text: []const u8) ![]u8 {
const r = rangeBytes(content, a, b);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..r.s]);
- try out.appendSlice(alloc, text);
- try out.appendSlice(alloc, content[r.e..]);
- return out.toOwnedSlice(alloc);
+ return spliceAlloc(alloc, content, r.s, r.e, text);
}
// `r<ch>`: overwrite every char in the inclusive range [a, b] with `ch` —
@@ -631,11 +635,12 @@ pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?str
const b = std.mem.trimStart(u8, next, " \t");
const start = lineStartOffset(content, row);
const rest = lineStartOffset(content, row + 1) + (next.len - b.len);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0 .. start + a.len]);
- try out.append(alloc, ' ');
- try out.appendSlice(alloc, content[rest..]);
- return .{ .content = try out.toOwnedSlice(alloc), .col = a.len };
+ const prefix_end = start + a.len;
+ const out = try alloc.alloc(u8, prefix_end + 1 + content.len - rest);
+ @memcpy(out[0..prefix_end], content[0..prefix_end]);
+ out[prefix_end] = ' ';
+ @memcpy(out[prefix_end + 1 ..], content[rest..]);
+ return .{ .content = out, .col = a.len };
}
// `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no
@@ -643,25 +648,52 @@ pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?str
pub const INDENT_W = 4;
pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
+ const lo = @min(r0, r1);
+ const hi = @max(r0, r1);
+ var out_len = content.len;
var it = std.mem.splitScalar(u8, content, '\n');
var row: usize = 0;
while (it.next()) |line| : (row += 1) {
- if (row > 0) try out.append(alloc, '\n');
- if (row >= @min(r0, r1) and row <= @max(r0, r1)) {
+ if (row < lo or row > hi) continue;
+ if (add) {
+ if (line.len != 0) out_len += INDENT_W;
+ } else {
+ var cut: usize = 0;
+ if (line.len > 0 and line[0] == '\t') {
+ cut = 1;
+ } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
+ out_len -= cut;
+ }
+ }
+
+ const out = try alloc.alloc(u8, out_len);
+ it = std.mem.splitScalar(u8, content, '\n');
+ row = 0;
+ var write: usize = 0;
+ while (it.next()) |line| : (row += 1) {
+ if (row > 0) {
+ out[write] = '\n';
+ write += 1;
+ }
+ var selected = line;
+ if (row >= lo and row <= hi) {
if (add) {
- if (line.len != 0) try out.appendSlice(alloc, " " ** INDENT_W); // empty lines stay empty (helix)
- try out.appendSlice(alloc, line);
+ if (line.len != 0) {
+ @memset(out[write..][0..INDENT_W], ' ');
+ write += INDENT_W;
+ }
+ } else if (line.len > 0 and line[0] == '\t') {
+ selected = line[1..];
} else {
var cut: usize = 0;
- if (line.len > 0 and line[0] == '\t') {
- cut = 1;
- } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
- try out.appendSlice(alloc, line[cut..]);
+ while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
+ selected = line[cut..];
}
- } else try out.appendSlice(alloc, line);
+ }
+ @memcpy(out[write..][0..selected.len], selected);
+ write += selected.len;
}
- return out.toOwnedSlice(alloc);
+ return out;
}
// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor
@@ -689,11 +721,8 @@ pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, de
else
std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null;
const off = lineStartOffset(content, c.row);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0 .. off + s]);
- try out.appendSlice(alloc, numstr);
- try out.appendSlice(alloc, content[off + e ..]);
- return .{ .content = try out.toOwnedSlice(alloc), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } };
+ const start = off + s;
+ return .{ .content = try spliceAlloc(alloc, content, start, off + e, numstr), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } };
}
// delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the
@@ -702,10 +731,7 @@ pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C
const s = lineStartOffset(content, a.row) + @min(a.col, lineSlice(content, a.row).len);
const e = lineStartOffset(content, b.row) + @min(b.col, lineSlice(content, b.row).len);
if (e <= s) return alloc.dupe(u8, content);
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(alloc, content[0..s]);
- try out.appendSlice(alloc, content[e..]);
- return out.toOwnedSlice(alloc);
+ return spliceAlloc(alloc, content, s, e, "");
}
// ---- helix range engine (phase 5) ----
@@ -1162,19 +1188,22 @@ pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]
var flen: usize = frag.len;
if (neg and !neg_after) flen -= 1;
if (!neg and neg_after) flen += 1;
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
var buf: [48]u8 = undefined;
// "{d}" prints '+' for positive signed ints — format the magnitude unsigned
const mag = std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null;
- if (neg_after) try out.append(alloc, '-');
- if (pad) {
- const want = flen - @as(usize, if (neg_after) 1 else 0);
- var i = mag.len;
- while (i < want) : (i += 1) try out.append(alloc, '0');
+ const sign_len: usize = @intFromBool(neg_after);
+ const want = if (pad) flen - sign_len else mag.len;
+ const digits_len = @max(want, mag.len);
+ const out = try alloc.alloc(u8, sign_len + digits_len);
+ var write: usize = 0;
+ if (neg_after) {
+ out[0] = '-';
+ write = 1;
}
- try out.appendSlice(alloc, mag);
- return try out.toOwnedSlice(alloc);
+ @memset(out[write..][0 .. digits_len - mag.len], '0');
+ write += digits_len - mag.len;
+ @memcpy(out[write..], mag);
+ return out;
}
test "hx word moves match helix" {
@@ -1333,10 +1362,35 @@ fn tagOf(key: Key) std.meta.Tag(Key) {
}
fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 {
- var ls: std.ArrayList([]const u8) = .empty;
+ const count = std.mem.count(u8, content, "\n") + 1;
+ const lines = try alloc.alloc([]const u8, count);
var it = std.mem.splitScalar(u8, content, '\n');
- while (it.next()) |ln| try ls.append(alloc, ln);
- return ls.toOwnedSlice(alloc);
+ var i: usize = 0;
+ while (it.next()) |line| : (i += 1) lines[i] = line;
+ return lines;
+}
+
+fn joinedLineRangeAlloc(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) ![]u8 {
+ var len = r1 - r0;
+ var r = r0;
+ while (true) : (r += 1) {
+ len += lineSlice(content, r).len;
+ if (r == r1) break;
+ }
+ const out = try alloc.alloc(u8, len);
+ var write: usize = 0;
+ r = r0;
+ while (true) : (r += 1) {
+ if (r > r0) {
+ out[write] = '\n';
+ write += 1;
+ }
+ const line = lineSlice(content, r);
+ @memcpy(out[write..][0..line.len], line);
+ write += line.len;
+ if (r == r1) break;
+ }
+ return out;
}
fn ensureVisible(view: View, row: usize) void {
@@ -1370,10 +1424,9 @@ fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lin
const r0: i32 = @min(st.msel_r0, st.msel_r1);
const r1: i32 = @max(st.msel_r0, st.msel_r1);
const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1)));
- if (yank.*) |old| alloc.free(old);
- yank.* = try alloc.dupe(u8, d.deleted);
- alloc.free(d.deleted);
const n = lineCount(d.content);
+ if (yank.*) |old| alloc.free(old);
+ yank.* = d.deleted;
st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 };
st.msel_active = false;
st.pending = 0;
@@ -1396,19 +1449,13 @@ fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank:
if (st.msel_active) {
const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
- var buf: std.ArrayList(u8) = .empty;
- var r: usize = r0;
- while (r <= r1) : (r += 1) {
- if (r > r0) try buf.append(alloc, '\n');
- try buf.appendSlice(alloc, lineSlice(content, r));
- }
+ const new_yank = try joinedLineRangeAlloc(alloc, content, r0, r1);
if (yank.*) |old| alloc.free(old);
- yank.* = try buf.toOwnedSlice(alloc);
- st.cur = .{ .row = r0, .col = 0 };
- st.msel_active = false;
+ yank.* = new_yank;
} else {
+ const new_yank = try alloc.dupe(u8, lineSlice(content, st.cur.row));
if (yank.*) |old| alloc.free(old);
- yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row));
+ yank.* = new_yank;
}
st.pending = 0;
}
@@ -1432,20 +1479,17 @@ fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lin
const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
// yank the original lines first (from the un-mutated content)
- var buf: std.ArrayList(u8) = .empty;
- var r: usize = r0;
- while (r <= r1) : (r += 1) {
- if (r > r0) try buf.append(alloc, '\n');
- try buf.appendSlice(alloc, lineSlice(content, r));
- }
- if (yank.*) |old| alloc.free(old);
- yank.* = try buf.toOwnedSlice(alloc);
+ const new_yank = try joinedLineRangeAlloc(alloc, content, r0, r1);
+ errdefer alloc.free(new_yank);
// drop the extra lines (keep one), then empty the kept line
const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null;
defer if (d) |dd| alloc.free(dd.deleted);
+ errdefer if (d) |dd| alloc.free(dd.content);
const after_del: []const u8 = if (d) |dd| dd.content else content;
const cl = try clearLine(alloc, after_del, r0);
if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy)
+ if (yank.*) |old| alloc.free(old);
+ yank.* = new_yank;
st.cur = .{ .row = r0, .col = 0 };
st.mode = .insert;
st.msel_active = false;
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 1bf14b7c..7b2d12d9 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -22,7 +22,7 @@ const File = pardes.File;
const file_pane = @import("file_pane.zig");
const modal = @import("modal.zig");
const builtins = @import("builtins.zig");
-const Builtin = builtins.Builtin();
+const Builtin = builtins.registry.Builtin();
const config = @import("config.zig");
const lsp = @import("lsp/lsp.zig");
/// the installed fonts, for openFonts. GUI only, behind the same comptime
@@ -122,40 +122,15 @@ pub fn traits(o: Origin) Traits {
return switch (o) {
// rows are `location text`, so n/N walk them
.search => .{ .name = config.search_buffer, .steps = true },
- .cmd => |b| switch (b) {
- // The builtin index. Neither steppable nor executable, and that is
- // a decision rather than an omission: its rows are not locations
- // (a look on `SPC` would go looking for a file called SPC), and
- // `executes` — which is exactly what makes ThemeSel a picker —
- // would mean walking the list runs Kill, Del and Delcol in turn.
- // Wearing a theme is free; wearing Kill ends the session. The
- // names in it are still live text, so middle-clicking the ONE you
- // want does the picking, which is all a picker was for.
- .Help => .{ .name = config.help_buffer },
- // the focus history, one location per row: not a search, but the
- // same kind of list, so n/N walk it and a row is a look target
- .Jumplist => .{ .name = config.jumps_buffer, .steps = true },
- // the theme ring, one `Theme <name>` per row. The only buffer whose
- // rows are COMMANDS rather than locations, so n/N execute them:
- // walking the list wears each theme, and stopping is picking one.
- .ThemeSel => .{ .name = config.themes_buffer, .steps = true, .executes = true },
- // the font picker, the theme picker one layer down: same rows,
- // same verb, its own name. It is a BRANCH and not a prong because
- // FontSel only exists in a gui build (see builtins.zig) — a prong
- // naming an enum field the tty fold does not have is a compile
- // error, and a comptime-false `if` is the one form that is not
- // even analysed there.
- //
- // Find (file names), Grep (file contents), and PdfSections all
- // list lookable locations. Feature-only rows remain branches so
- // builds whose Builtin enum omits them never analyse the field.
- else => blk: {
- if (pardes.platform == .gui) if (b == .FontSel)
- break :blk .{ .name = config.fonts_buffer, .steps = true, .executes = true };
- if (pardes.pdf_enabled) if (b == .PdfSections)
- break :blk .{ .name = config.pdf_sections_buffer, .steps = true };
+ .cmd => |b| blk: {
+ const meta = builtins.registry.outputTraits(b) orelse
break :blk .{ .name = config.search_buffer, .steps = true };
- },
+ break :blk .{
+ .name = meta.name,
+ .steps = meta.steps,
+ .jumps = meta.jumps,
+ .executes = meta.executes,
+ };
},
.query => |k| switch (k) {
.hover => .{ .name = config.hover_buffer },
@@ -232,6 +207,7 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
var out: Output = .{ .from = from };
setArg(&out, arg);
pane.file = .{ .path = path, .content = content, .output = out };
+ pane.kind = .file;
pane.cur_pinned = true;
return pane;
}
@@ -257,11 +233,9 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
///
/// `content` is gpa-owned: adopted by the buffer, or freed here when there is
/// nowhere to put it. `anchor` is the row n/N step from, null for the top.
-pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) void {
- const pane = p.panes[id] orelse {
- p.gpa.free(content);
- return;
- };
+pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) !void {
+ errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
const by_arg = std.meta.activeTag(from) != .query;
for (p.panes, 0..) |slot, i| {
if (i == id) continue;
@@ -286,14 +260,8 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
}
return;
}
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = open(p, free, dir, from, arg, content) catch {
- p.gpa.free(content);
- return;
- };
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, from, arg, content);
p.placeDoc(id, free, np);
p.active = id;
// prose is not a list of locations: n/N over a hover blurb would step to
@@ -305,6 +273,11 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
}
}
+fn putPrint(dst: []u8, at: *usize, comptime fmt: []const u8, args: anytype) void {
+ const text = std.fmt.bufPrint(dst[at.*..], fmt, args) catch unreachable;
+ at.* += text.len;
+}
+
/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
/// one row per location, oldest first — the same `location text` shape every
/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
@@ -322,9 +295,9 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
/// image — has no file to point at and gets `@pN`. The trailing text is the
/// content line for anything holding text, else the pane's directory: enough
/// to recognise the place without opening it.
-pub fn openJumps(p: *Pardes, id: usize) void {
+pub fn openJumps(p: *Pardes, id: usize) !void {
const arena = p.scratch.allocator();
- var out: std.ArrayList(u8) = .empty;
+ var len: usize = 0;
for (p.jumps[0..p.njumps]) |j| {
const jp = p.panes[j.pane] orelse continue;
var idbuf: [16]u8 = undefined;
@@ -333,7 +306,7 @@ pub fn openJumps(p: *Pardes, id: usize) void {
const loc: []const u8 = if (has_path)
(if (jp.file) |f| f.path else pdf_path.?)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
const what: []const u8 = if (jp.file) |f|
std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
else if (jp.image) |iv|
@@ -342,21 +315,42 @@ pub fn openJumps(p: *Pardes, id: usize) void {
path
else
jp.cwdSlice();
- // one line of a file can be the whole file: cut it, but never mid
- // codepoint — a partial UTF-8 sequence reaches the renderer as a row
- // and there is nothing sane for it to draw (grepText cuts the same way)
var cut = @min(what.len, 120);
while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
- // line 0 is a place with no spot in it (a shell whose cursor is still
- // the program's): it is written WITHOUT the suffix, which is the same
- // thing a bare path has always meant to a look.
- const row = if (j.line == 0)
- std.fmt.allocPrint(arena, "{s} {s}\n", .{ loc, what[0..cut] }) catch return
+ len += if (j.line == 0)
+ std.fmt.count("{s} {s}\n", .{ loc, what[0..cut] })
else
- std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }) catch return;
- out.appendSlice(arena, row) catch return;
+ std.fmt.count("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
}
- openStepped(p, id, .{ .cmd = .Jumplist }, out.items);
+
+ const out = try arena.alloc(u8, len);
+ var at: usize = 0;
+ for (p.jumps[0..p.njumps]) |j| {
+ const jp = p.panes[j.pane] orelse continue;
+ var idbuf: [16]u8 = undefined;
+ const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null;
+ const has_path = if (jp.file) |f| f.output == null else pdf_path != null;
+ const loc: []const u8 = if (has_path)
+ (if (jp.file) |f| f.path else pdf_path.?)
+ else
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
+ const what: []const u8 = if (jp.file) |f|
+ std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
+ else if (jp.image) |iv|
+ iv.path
+ else if (pdf_path) |path|
+ path
+ else
+ jp.cwdSlice();
+ var cut = @min(what.len, 120);
+ while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
+ if (j.line == 0)
+ putPrint(out, &at, "{s} {s}\n", .{ loc, what[0..cut] })
+ else
+ putPrint(out, &at, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
+ }
+ std.debug.assert(at == out.len);
+ try openStepped(p, id, .{ .cmd = .Jumplist }, out);
}
/// The ThemeSel builtin: the theme ring written out as one `Theme <name>` row
@@ -369,13 +363,17 @@ pub fn openJumps(p: *Pardes, id: usize) void {
/// The command's own name comes from the builtin rather than a literal: the
/// word is derived from the struct in exactly one place (builtins.word), and a
/// rename there must not leave rows here that name something that is gone.
-pub fn openThemes(p: *Pardes, id: usize) void {
+pub fn openThemes(p: *Pardes, id: usize) !void {
const arena = p.scratch.allocator();
- var out: std.ArrayList(u8) = .empty;
- for (pardes.themes) |t| {
- out.print(arena, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name}) catch return;
- }
- openStepped(p, id, .{ .cmd = .ThemeSel }, out.items);
+ var len: usize = 0;
+ for (pardes.themes) |t|
+ len += std.fmt.count(comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
+ const out = try arena.alloc(u8, len);
+ var at: usize = 0;
+ for (pardes.themes) |t|
+ putPrint(out, &at, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
+ std.debug.assert(at == out.len);
+ try openStepped(p, id, .{ .cmd = .ThemeSel }, out);
}
/// The FontSel builtin: openThemes over the fonts installed on the machine
@@ -388,14 +386,19 @@ pub fn openThemes(p: *Pardes, id: usize) void {
/// the FontSel origin both exist only there, and a comptime-false `if` is what
/// keeps the tty build from analysing either. The dead parameters on that
/// build are the honest shape of "this platform cannot open one".
-pub fn openFonts(p: *Pardes, id: usize) void {
+pub fn openFonts(p: *Pardes, id: usize) !void {
if (pardes.platform == .gui) {
const arena = p.scratch.allocator();
- var out: std.ArrayList(u8) = .empty;
- for (fonts.list(arena, null)) |f| {
- out.print(arena, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name}) catch return;
- }
- openStepped(p, id, .{ .cmd = .FontSel }, out.items);
+ const font_list = fonts.list(arena, null);
+ var len: usize = 0;
+ for (font_list) |f|
+ len += std.fmt.count(comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
+ const out = try arena.alloc(u8, len);
+ var at: usize = 0;
+ for (font_list) |f|
+ putPrint(out, &at, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
+ std.debug.assert(at == out.len);
+ try openStepped(p, id, .{ .cmd = .FontSel }, out);
}
}
@@ -405,18 +408,13 @@ pub fn openFonts(p: *Pardes, id: usize) void {
///
/// Focus stays with the pane that ASKED, exactly as it does after a search:
/// n/N are read there, and they step the buffer they just armed.
-fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) void {
- const pane = p.panes[id] orelse return;
- const content = p.gpa.dupe(u8, text) catch return;
+fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
+ const pane = p.panes[id] orelse return error.MissingPane;
+ const content = try p.gpa.dupe(u8, text);
+ errdefer p.gpa.free(content);
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = open(p, free, dir, from, "", content) catch {
- p.gpa.free(content);
- return;
- };
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, from, "", content);
p.placeDoc(id, free, np);
p.active = id;
pane.search_pane = free;
@@ -435,28 +433,46 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) void {
/// SPC cannot reach at all, so the reference page IS the filter's degenerate
/// case. A second builtin would have been a second renderer over a superset of
/// these rows, and the two would have drifted the first time a column moved.
-pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void {
- const pane = p.panes[id] orelse return;
- const arena = p.scratch.allocator();
- var out: std.ArrayList(u8) = .empty;
+pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
+ const pane = p.panes[id] orelse return error.MissingPane;
+ const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n";
+ const group_header = "pardes builtins under SPC";
+ var len: usize = if (prefix.len == 0)
+ full_header.len
+ else
+ group_header.len + prefix.len * 2 + 2;
+ for (pardes.builtin_rows) |row| {
+ // a path-less builtin filters as the empty path: in the full listing
+ // (which starts with nothing) and out of every group
+ if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
+ len += row.line.len + 1;
+ }
+ const content = try p.gpa.alloc(u8, len);
+ errdefer p.gpa.free(content);
+ var at: usize = 0;
if (prefix.len == 0) {
- // the header names the four KINDS of shortcut a row's columns can
- // hold, because a blank column is only readable once you know what
- // would have been in it
- out.appendSlice(arena, "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n") catch return;
+ @memcpy(content[0..full_header.len], full_header);
+ at = full_header.len;
} else {
- out.appendSlice(arena, "pardes builtins under SPC") catch return;
- for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return;
- out.appendSlice(arena, "\n\n") catch return;
+ @memcpy(content[0..group_header.len], group_header);
+ at = group_header.len;
+ for (prefix) |c| {
+ content[at] = ' ';
+ content[at + 1] = c;
+ at += 2;
+ }
+ content[at] = '\n';
+ content[at + 1] = '\n';
+ at += 2;
}
for (pardes.builtin_rows) |row| {
- // a path-less builtin filters as the empty path: in the full listing
- // (which starts with nothing) and out of every group
if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
- out.appendSlice(arena, row.line) catch return;
- out.append(arena, '\n') catch return;
+ @memcpy(content[at..][0..row.line.len], row.line);
+ at += row.line.len;
+ content[at] = '\n';
+ at += 1;
}
- const content = p.gpa.dupe(u8, out.items) catch return;
+ std.debug.assert(at == content.len);
// the buffer says what made it, so finding the open one is asking that and
// not matching its name
for (p.panes, 0..) |slot, i| {
@@ -473,13 +489,8 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void {
return;
}
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = open(p, free, dir, .{ .cmd = .Help }, prefix, content) catch {
- p.gpa.free(content);
- return;
- };
+ const free = p.freeSlot() orelse return error.NoPaneSlots;
+ const np = try open(p, free, dir, .{ .cmd = .Help }, prefix, content);
p.placeDoc(id, free, np);
+ p.active = free;
}
diff --git a/src/pardes.zig b/src/pardes.zig
index ef606f23..fd33cafd 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -26,7 +26,7 @@ const mvzr = @import("mvzr");
const modal = @import("modal.zig");
const normal_input = @import("normal_input.zig");
const look = @import("look.zig");
-const syntax = @import("syntax.zig");
+pub const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
const term_pane = @import("term_pane.zig");
const file_pane = @import("file_pane.zig");
@@ -35,7 +35,9 @@ const builtins = @import("builtins.zig");
const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
pub const pdf_enabled = @import("pardes_config").mupdf;
-const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {};
+pub const pdf = if (pdf_enabled) @import("mupdf") else struct {};
+const pdf_impl = pdf;
+pub const allocators = @import("allocators.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
@@ -359,7 +361,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
const revision_before_search = pane.pdf.?.render_revision;
- p.runSearch(0, "Pardes", .text, .top);
+ try p.runSearch(0, "Pardes", .text, .top);
try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision);
const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
@@ -374,7 +376,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
const long_query = "a query deliberately longer than any tag display budget: " ++
"012345678901234567890123456789012345678901234567890123456789" ++
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
- p.setPdfSearchQuery(&pane.pdf.?, long_query);
+ try p.setPdfSearchQuery(&pane.pdf.?, long_query);
try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0;
p.setPdfPage(pane, next_page);
@@ -456,7 +458,7 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac
try std.testing.expectEqual(@as(u32, 0), pane.count);
try std.testing.expectEqual(@as(u21, 0), pane.pending);
try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0);
- try std.testing.expect(!pane.pipe_edit and !pane.tag_edit);
+ try std.testing.expect(!pane.hasPipePrompt() and !pane.tag_edit);
// Cross-pane BODY-NORMAL actions keep their established shared paths.
p.update(.{ .key = .{ .cp = ':' } });
@@ -469,9 +471,9 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac
p.update(.{ .key = .{ .cp = '!' } });
try std.testing.expect(!p.leader_on);
p.update(.{ .key = .{ .cp = '/' } });
- try std.testing.expect(pane.search_edit and pane.tag_edit);
+ try std.testing.expect(pane.hasSearchPrompt() and pane.tag_edit);
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.search_edit and !pane.tag_edit);
+ try std.testing.expect(!pane.hasSearchPrompt() and !pane.tag_edit);
}
test "PDF continuous strip renders every intersecting short page" {
@@ -499,10 +501,11 @@ test "PDF continuous strip renders every intersecting short page" {
const visible = Pardes.pdfVisiblePages(pv, viewport);
try std.testing.expectEqual(pv.page_count, visible.len);
const request = p.pdfRenderRequest(pane).?;
+ try std.testing.expect(pv.page_count <= pv.rasters.len);
for (0..pv.page_count) |page| {
const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4);
@memset(rgba, @intCast(page));
- try pv.rasters.append(gpa, .{
+ pv.rasters[pv.rasters_len] = .{
.valid = true,
.page = page,
.rgba = rgba,
@@ -513,14 +516,15 @@ test "PDF continuous strip renders every intersecting short page" {
.tried = true,
.tint_key = p.pdfTintKey(pv),
.revision = @intCast(page + 1),
- });
+ };
+ pv.rasters_len += 1;
}
var frame = std.heap.ArenaAllocator.init(gpa);
defer frame.deinit();
const surface = try p.render(frame.allocator());
try std.testing.expectEqual(pv.page_count, surface.nimages);
- try std.testing.expectEqual(pv.page_count, pv.rasters.items.len);
+ try std.testing.expectEqual(pv.page_count, pv.rasters_len);
for (surface.images[0..surface.nimages], 0..) |maybe, page| {
const place = maybe orelse return error.MissingShortPdfPage;
try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page);
@@ -607,7 +611,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
const default_surface = try p.render(frame.allocator());
try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
try std.testing.expectEqual(@as(usize, 2), default_surface.nimages);
- try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
const Snapshot = struct {
page: u32,
@@ -648,14 +652,14 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
}
};
const default_filtered = try Capture.get(default_surface);
- for (pv.rasters.items) |raster|
+ for (pv.rasters[0..pv.rasters_len]) |raster|
try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode);
const dark_index = for (themes, 0..) |th, i| {
if (std.mem.eql(u8, th.name, "dark")) break i;
} else return error.MissingDarkTheme;
p.setThemeIndex(dark_index);
- for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried);
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried);
_ = frame.reset(.retain_capacity);
const themed_surface = try p.render(frame.allocator());
const themed = try Capture.get(themed_surface);
@@ -690,7 +694,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
for (themed, full) |old, new| {
try std.testing.expect(new.revision > old.revision);
}
- for (pv.rasters.items) |raster|
+ for (pv.rasters[0..pv.rasters_len]) |raster|
try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
p.togglePdfTint(pane);
@@ -707,11 +711,11 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
const acme_index = for (themes, 0..) |th, i| {
if (std.mem.eql(u8, th.name, "acme")) break i;
} else return error.MissingAcmeTheme;
- for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
p.setThemeIndex(acme_index);
// Disabled keys ignore theme colors, so neither explicit invalidation nor
// the per-raster key comparison asks MuPDF for replacement pixels.
- for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
_ = frame.reset(.retain_capacity);
const unchanged_surface = try p.render(frame.allocator());
const unchanged = try Capture.get(unchanged_surface);
@@ -720,7 +724,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
try std.testing.expectEqual(old.revision, new.revision);
try std.testing.expectEqual(old.checksum, new.checksum);
}
- try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
}
test "PDF resize preserves page-relative document position" {
@@ -843,7 +847,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
defer page_hits.deinit(gpa);
try std.testing.expect(page_hits.hit_count >= 3);
- p.runSearch(0, "Pardes", .text, .top);
+ try p.runSearch(0, "Pardes", .text, .top);
const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
const rows = p.panes[results_id].?.file.?.content;
try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null);
@@ -1223,7 +1227,7 @@ const legacy_file_pane_tail = " Save Del";
// to keep in sync. It lands here rather than in builtins.zig because a
// container cannot hold a decl folded out of its own decl list, and here it
// sits with the other two comptime folds (builtin_rows, the topbar check).
-const Builtin = builtins.Builtin();
+const Builtin = builtins.registry.Builtin();
test "PDF builtins and leader paths follow the MuPDF feature gate" {
const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
@@ -1458,11 +1462,11 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations"
// The same path:page:col token from any non-PdfSections pane retains the
// established PDF-search meaning for col; no outline reveal is installed.
- p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics");
+ try p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics");
p.lookAt(1, child_target);
try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.search_hit);
try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null);
- p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
+ try p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
// Step forward onto the external row: it uses the existing open_link
// effect and searchStep restores focus to the owning PDF.
@@ -1559,12 +1563,12 @@ test "selection pipe prompt submits exact request and Escape cancels" {
pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expect(pane.tag_edit and pane.pipe_edit);
- try std.testing.expect(std.mem.endsWith(u8, pane.tag_tail.items, config.pipe_marker));
+ try std.testing.expect(pane.tag_edit and pane.hasPipePrompt());
+ try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker));
try std.testing.expect(nextPipeEffect(p) == null);
p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } });
p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(!pane.tag_edit and !pane.pipe_edit);
+ try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt());
const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
const request = p.pipeRequest(id) orelse return error.MissingPipeRequest;
try std.testing.expectEqualStrings("tr a-z A-Z", request.command);
@@ -1576,7 +1580,7 @@ test "selection pipe prompt submits exact request and Escape cancels" {
p.update(.{ .key = .{ .cp = '|' } });
p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit and !pane.pipe_edit);
+ try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt());
try std.testing.expectEqualSlices(u8, before, pane.file.?.content);
try std.testing.expect(nextPipeEffect(p) == null);
}
@@ -1606,7 +1610,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" {
const outputs: []const []const u8 = &.{ "AA\n", "" };
p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } });
try std.testing.expectEqualSlices(u8, "AA\n bb \n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len);
try std.testing.expectEqual(@as(u8, 1), pane.nsel);
p.update(.{ .key = .{ .cp = 'u' } });
@@ -1628,7 +1632,7 @@ test "selection pipe failure and stale completion never mutate the file" {
const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } });
try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len);
p.update(.{ .key = .{ .cp = '|' } });
p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
@@ -1637,7 +1641,7 @@ test "selection pipe failure and stale completion never mutate the file" {
file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n"));
p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } });
try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len);
}
test "selection pipe rejects a reused pane slot and a superseded request" {
@@ -1656,7 +1660,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" {
pane = try p.hxOpenFileContent("new\n"); // same slot, different serial
p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } });
try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len);
pane.cur_col = 2;
pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
@@ -1674,7 +1678,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" {
try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content);
p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } });
try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo.items.len);
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len);
}
test "selection pipe binding is file-normal-only" {
@@ -1686,11 +1690,11 @@ test "selection pipe binding is file-normal-only" {
pane.file.?.output = .{ .from = .search };
p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expect(!pane.pipe_edit);
+ try std.testing.expect(!pane.hasPipePrompt());
pane.file.?.output = null;
pane.mode = .insert;
p.update(.{ .key = .{ .cp = '|', .text = "|" } });
- try std.testing.expect(!pane.pipe_edit);
+ try std.testing.expect(!pane.hasPipePrompt());
try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content);
}
@@ -1713,7 +1717,7 @@ test "startup config runs builtin lines in order and isolates bad lines" {
try std.testing.expect(!p.themeAnimationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
try std.testing.expect(!p.quit);
- for (p.effects.items) |effect| switch (effect) {
+ for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) {
.write => |w| {
try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null);
try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null);
@@ -1820,8 +1824,8 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
// Argument-taking builtins use the same gesture path: a tag sweep is the
// complete command line, and Theme consumes its tail without a PTY write.
- pane.tag_tail.clearRetainingCapacity();
- try pane.tag_tail.appendSlice(gpa, " Theme dark");
+ pane.tag_tail_len = 0;
+ try std.testing.expect(pane.appendTag(" Theme dark"));
pane.tag_init = true;
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } });
@@ -1840,21 +1844,23 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
// A word absent from the builtin vocabulary takes the fallback, including
// the terminating carriage return expected by the shell prompt.
- pane.tag_tail.clearRetainingCapacity();
- try pane.tag_tail.appendSlice(gpa, " DefinitelyNotABuiltin");
+ pane.tag_tail_len = 0;
+ try std.testing.expect(pane.appendTag(" DefinitelyNotABuiltin"));
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
- var sent: std.ArrayList(u8) = .empty;
- defer sent.deinit(gpa);
+ var sent: [256]u8 = undefined;
+ var sent_len: usize = 0;
while (p.nextEffect()) |effect| switch (effect) {
.write => |w| {
try std.testing.expectEqual(@as(u8, 0), w.pane);
- try sent.appendSlice(gpa, w.bytes.slice());
+ try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
},
.new_file => return error.UnknownCommandRanNew,
else => {},
};
- try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items);
+ try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]);
}
test "an untouched tagline ends where its layout column's widest one does" {
@@ -1904,7 +1910,7 @@ test "an untouched tagline ends where its layout column's widest one does" {
// builtins. (A plain click seeds the tail, so the alternative is every
// other tagline in the column snapping left the moment you click one.)
p.seedTail(below);
- try below.tag_tail.appendSlice(gpa, " lots and lots of typing out here");
+ try std.testing.expect(below.appendTag(" lots and lots of typing out here"));
try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len);
// A pane squeezed off the bottom is not drawn, so it stops voting and the
@@ -1968,20 +1974,23 @@ test "unknown Exec from an image writes to a terminal in the image directory" {
p.setCwd(0, "/tmp/pardes-image-dir");
const image_pane = try p.newDocPane(1);
image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
+ image_pane.kind = .image;
try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane));
try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback"));
- var sent: std.ArrayList(u8) = .empty;
- defer sent.deinit(gpa);
+ var sent: [256]u8 = undefined;
+ var sent_len: usize = 0;
while (p.nextEffect()) |effect| switch (effect) {
.write => |w| {
try std.testing.expectEqual(@as(u8, 0), w.pane);
- try sent.appendSlice(gpa, w.bytes.slice());
+ try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
},
.spawn => return error.ImageExecSpawnedDespiteMatchingTerminal,
else => {},
};
- try std.testing.expectEqualStrings("echo image-fallback\r", sent.items);
+ try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
}
test "New completes as an empty watched file in the calling column and focuses it" {
@@ -2118,6 +2127,11 @@ test "New requests repeat, stay anchored, and reject stale or full completions"
else => {},
};
try std.testing.expect(!emitted_when_full);
+ // Builtins that need another pane surface capacity/allocation failures on
+ // the asking pane instead of silently doing nothing.
+ const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin;
+ p.runBuiltin(help, source, "", null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null);
}
test "startup config overrides restored styling before initFromDump returns" {
@@ -2169,10 +2183,28 @@ fn chordName(comptime c: config.Chord) []const u8 {
/// with no word to index by. The day one of them becomes a builtin — a struct
/// in builtins.zig and a `cmd` column beside its binding — it appears here for
/// free, which is the upgrade path rather than a special case here.
+fn shortcutCount(comptime b: Builtin) comptime_int {
+ var count = 0;
+ for (config.window_keys) |wk| if (wk.cmd == b) {
+ count += 2;
+ };
+ for (config.jump_keys) |jk| if (jk.cmd == b) {
+ count += 1;
+ };
+ if (config.look_cmd == b) count += config.look_key.len + 1;
+ if (config.exec_cmd == b) count += config.exec_key.len + 1;
+ var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' ');
+ while (it.next()) |w| if (std.mem.eql(u8, w, @tagName(b))) {
+ count += 1;
+ };
+ return count;
+}
+
fn shortcuts(comptime b: Builtin) []const u8 {
comptime {
@setEvalBranchQuota(20000);
- var parts: []const []const u8 = &.{};
+ var parts: [shortcutCount(b)][]const u8 = undefined;
+ var part: usize = 0;
// both spellings of the four directional moves: the arrow is a real
// key someone presses, and the table carries it precisely so it is
// discoverable from the builtin as well as the other way round
@@ -2180,29 +2212,72 @@ fn shortcuts(comptime b: Builtin) []const u8 {
if (wk.cmd != b) continue;
// the prefix has one spelling and this is it
const pfx = chordName(config.window_prefix[0]) ++ " ";
- parts = parts ++ &[_][]const u8{ pfx ++ chordName(wk.letter), pfx ++ chordName(wk.arrow) };
+ parts[part] = pfx ++ chordName(wk.letter);
+ parts[part + 1] = pfx ++ chordName(wk.arrow);
+ part += 2;
}
for (config.jump_keys) |jk| {
- if (jk.cmd == b) parts = parts ++ &[_][]const u8{chordName(jk.chord)};
+ if (jk.cmd == b) {
+ parts[part] = chordName(jk.chord);
+ part += 1;
+ }
}
// the two acme verbs, a key and a mouse button each. Asked of the
// BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look
// and Exec by name, so pointing look_cmd at Grep moves the row.
if (config.look_cmd == b) {
- for (config.look_key) |k| parts = parts ++ &[_][]const u8{chordName(k)};
- parts = parts ++ &[_][]const u8{@tagName(config.look_button) ++ "-click"};
+ for (config.look_key) |k| {
+ parts[part] = chordName(k);
+ part += 1;
+ }
+ parts[part] = @tagName(config.look_button) ++ "-click";
+ part += 1;
}
if (config.exec_cmd == b) {
- for (config.exec_key) |k| parts = parts ++ &[_][]const u8{chordName(k)};
- parts = parts ++ &[_][]const u8{@tagName(config.exec_button) ++ "-click"};
+ for (config.exec_key) |k| {
+ parts[part] = chordName(k);
+ part += 1;
+ }
+ parts[part] = @tagName(config.exec_button) ++ "-click";
+ part += 1;
}
var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' ');
while (it.next()) |w| {
- if (std.mem.eql(u8, w, @tagName(b))) parts = parts ++ &[_][]const u8{"topbar"};
+ if (std.mem.eql(u8, w, @tagName(b))) {
+ parts[part] = "topbar";
+ part += 1;
+ }
}
- var out: []const u8 = "";
- for (parts, 0..) |s, i| out = out ++ (if (i > 0) ", " else "") ++ s;
- return out;
+ var len: usize = if (parts.len == 0) 0 else 2 * (parts.len - 1);
+ for (parts) |text| len += text.len;
+ var out: [len]u8 = undefined;
+ var at: usize = 0;
+ for (parts, 0..) |text, i| {
+ if (i > 0) {
+ @memcpy(out[at..][0..2], ", ");
+ at += 2;
+ }
+ @memcpy(out[at..][0..text.len], text);
+ at += text.len;
+ }
+ return &out;
+ }
+}
+
+fn leaderKeys(comptime path: []const u8) []const u8 {
+ comptime {
+ var out: [4 + 2 * path.len - 1]u8 = undefined;
+ @memcpy(out[0..4], "SPC ");
+ var at: usize = 4;
+ for (path, 0..) |c, i| {
+ if (i > 0) {
+ out[at] = ' ';
+ at += 1;
+ }
+ out[at] = c;
+ at += 1;
+ }
+ return &out;
}
}
@@ -2236,14 +2311,7 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: {
namew = @max(namew, @tagName(b).len);
}
for (std.enums.values(Builtin), 0..) |b, i| {
- var keys: []const u8 = "";
- if (config.leader_path.get(b)) |path| {
- keys = "SPC ";
- for (path, 0..) |c, k| {
- if (k > 0) keys = keys ++ " ";
- keys = keys ++ &[_]u8{c};
- }
- }
+ const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else "";
// the name column is only padded when something follows it: a row
// whose builtin has no other shortcut ends at the name, so the listing
// carries no trailing whitespace
@@ -2317,6 +2385,10 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 {
const UNDO_MAX = 256;
const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter
+const PDF_RASTER_MAX = 256; // enough for every visible page in a pathological tiny-page viewport
+const TAG_TAIL_CAP = 4096; // one editable command line; extra input is refused
+const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record
+const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writes
/// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own
/// default cell showing (the native-dark shape); `palette` null = let a child's
@@ -2374,14 +2446,15 @@ const curated = struct {
///
/// A FUNCTION and not a const for the same reason builtins.all is one: it is
/// only ever a signature to whoever walks it, never a value in its own way.
-fn fold(comptime C: type) []const Theme {
+fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
comptime {
- @setEvalBranchQuota(400000); // 228 themes x 15 fields, each field a @field lookup, and the array re-concatenated per theme
- var out: []const Theme = &.{};
- for (@typeInfo(C).@"struct".decls) |d| {
+ @setEvalBranchQuota(400000);
+ var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined;
+ for (@typeInfo(C).@"struct".decls, 0..) |d, i| {
var t: Theme = undefined;
- for (@typeInfo(Theme).@"struct".fields) |f| @field(t, f.name) = @field(@field(C, d.name).theme, f.name);
- out = out ++ &[_]Theme{t};
+ for (@typeInfo(Theme).@"struct".fields) |f|
+ @field(t, f.name) = @field(@field(C, d.name).theme, f.name);
+ out[i] = t;
}
return out;
}
@@ -2780,6 +2853,21 @@ const sel_slot = @intFromEnum(config.select_button);
fn isPrefix(key: Key, cp: u21) bool {
return key.cp == cp and !key.ctrl and !key.alt;
}
+fn roleBindingName(comptime role: normal_input.Role) []const u8 {
+ return switch (role) {
+ .prefix_goto => "goto_prefix",
+ .prefix_view => "view_prefix",
+ .prefix_match => "match_prefix",
+ .prefix_find_fwd => "find_char_fwd",
+ .prefix_find_back => "find_char_back",
+ .prefix_till_fwd => "till_char_fwd",
+ .prefix_till_back => "till_char_back",
+ .prefix_replace => "replace_prefix",
+ .prefix_next => "next_prefix",
+ .prefix_prev => "prev_prefix",
+ else => @tagName(role),
+ };
+}
/// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry
/// several roles (`h` is both move-left and `gh`'s line-start); the pure
@@ -2792,127 +2880,19 @@ fn normalInput(key: Key) normal_input.Input {
.ctrl = key.ctrl,
.alt = key.alt,
};
- const roles = &out.roles;
- if (hit(key, config.escape)) roles.insert(.escape);
-
- if (isPrefix(key, config.goto_prefix)) roles.insert(.prefix_goto);
- if (isPrefix(key, config.view_prefix)) roles.insert(.prefix_view);
- if (isPrefix(key, config.match_prefix)) roles.insert(.prefix_match);
- if (isPrefix(key, config.find_char_fwd)) roles.insert(.prefix_find_fwd);
- if (isPrefix(key, config.find_char_back)) roles.insert(.prefix_find_back);
- if (isPrefix(key, config.till_char_fwd)) roles.insert(.prefix_till_fwd);
- if (isPrefix(key, config.till_char_back)) roles.insert(.prefix_till_back);
- if (isPrefix(key, config.replace_prefix)) roles.insert(.prefix_replace);
- if (isPrefix(key, config.next_prefix)) roles.insert(.prefix_next);
- if (isPrefix(key, config.prev_prefix)) roles.insert(.prefix_prev);
-
- if (hit(key, config.goto_file_start)) roles.insert(.goto_file_start);
- if (hit(key, config.goto_last_line)) roles.insert(.goto_last_line);
- if (hit(key, config.goto_line_start)) roles.insert(.goto_line_start);
- if (hit(key, config.goto_line_end)) roles.insert(.goto_line_end);
- if (hit(key, config.goto_first_nonws)) roles.insert(.goto_first_nonws);
- if (hit(key, config.goto_line_down)) roles.insert(.goto_line_down);
- if (hit(key, config.goto_line_up)) roles.insert(.goto_line_up);
- if (hit(key, config.goto_column)) roles.insert(.goto_column);
- if (hit(key, config.goto_view_top)) roles.insert(.goto_view_top);
- if (hit(key, config.goto_view_center)) roles.insert(.goto_view_center);
- if (hit(key, config.goto_view_bottom)) roles.insert(.goto_view_bottom);
- if (hit(key, config.goto_definition)) roles.insert(.goto_definition);
- if (hit(key, config.goto_declaration)) roles.insert(.goto_declaration);
- if (hit(key, config.goto_type_definition)) roles.insert(.goto_type_definition);
- if (hit(key, config.goto_implementation)) roles.insert(.goto_implementation);
- if (hit(key, config.goto_references)) roles.insert(.goto_references);
-
- if (hit(key, config.view_top)) roles.insert(.view_top);
- if (hit(key, config.view_center)) roles.insert(.view_center);
- if (hit(key, config.view_bottom)) roles.insert(.view_bottom);
- if (hit(key, config.view_scroll_down)) roles.insert(.view_scroll_down);
- if (hit(key, config.view_scroll_up)) roles.insert(.view_scroll_up);
-
- if (isPrefix(key, config.match_inside)) roles.insert(.match_inside);
- if (isPrefix(key, config.match_around)) roles.insert(.match_around);
- if (isPrefix(key, config.surround_add)) roles.insert(.surround_add);
- if (isPrefix(key, config.surround_replace)) roles.insert(.surround_replace);
- if (isPrefix(key, config.surround_delete)) roles.insert(.surround_delete);
-
- if (hit(key, config.goto_paragraph)) roles.insert(.goto_paragraph);
- if (hit(key, config.add_newline)) roles.insert(.add_newline);
- if (hit(key, config.goto_diagnostic)) roles.insert(.goto_diagnostic);
- if (hit(key, config.goto_diagnostic_end)) roles.insert(.goto_diagnostic_end);
-
- if (hit(key, config.move_left)) roles.insert(.move_left);
- if (hit(key, config.move_right)) roles.insert(.move_right);
- if (hit(key, config.move_down)) roles.insert(.move_down);
- if (hit(key, config.move_up)) roles.insert(.move_up);
- if (hit(key, config.next_word_start)) roles.insert(.next_word_start);
- if (hit(key, config.prev_word_start)) roles.insert(.prev_word_start);
- if (hit(key, config.next_word_end)) roles.insert(.next_word_end);
- if (hit(key, config.next_long_word_start)) roles.insert(.next_long_word_start);
- if (hit(key, config.prev_long_word_start)) roles.insert(.prev_long_word_start);
- if (hit(key, config.next_long_word_end)) roles.insert(.next_long_word_end);
- if (hit(key, config.repeat_find)) roles.insert(.repeat_find);
- if (hit(key, config.line_start)) roles.insert(.line_start);
- if (hit(key, config.line_end)) roles.insert(.line_end);
- if (hit(key, config.line_first_nonws)) roles.insert(.line_first_nonws);
- if (hit(key, config.goto_line)) roles.insert(.goto_line);
- if (hit(key, config.half_page_down)) roles.insert(.half_page_down);
- if (hit(key, config.half_page_up)) roles.insert(.half_page_up);
- if (hit(key, config.page_down)) roles.insert(.page_down);
- if (hit(key, config.page_up)) roles.insert(.page_up);
-
- if (hit(key, config.insert)) roles.insert(.insert);
- if (hit(key, config.append)) roles.insert(.append);
- if (hit(key, config.insert_line_start)) roles.insert(.insert_line_start);
- if (hit(key, config.insert_line_end)) roles.insert(.insert_line_end);
- if (hit(key, config.open_below)) roles.insert(.open_below);
- if (hit(key, config.open_above)) roles.insert(.open_above);
-
- if (hit(key, config.select_mode)) roles.insert(.select_mode);
- if (hit(key, config.select_line)) roles.insert(.select_line);
- if (hit(key, config.select_line_bounds)) roles.insert(.select_line_bounds);
- if (hit(key, config.shrink_to_line_bounds)) roles.insert(.shrink_to_line_bounds);
- if (hit(key, config.collapse_selection)) roles.insert(.collapse_selection);
- if (hit(key, config.flip_selection)) roles.insert(.flip_selection);
- if (hit(key, config.select_all)) roles.insert(.select_all);
- if (hit(key, config.copy_sel_below)) roles.insert(.copy_sel_below);
- if (hit(key, config.copy_sel_above)) roles.insert(.copy_sel_above);
- if (hit(key, config.keep_primary_sel)) roles.insert(.keep_primary_sel);
- if (hit(key, config.remove_primary_sel)) roles.insert(.remove_primary_sel);
- if (hit(key, config.rotate_sel_fwd)) roles.insert(.rotate_sel_fwd);
- if (hit(key, config.rotate_sel_back)) roles.insert(.rotate_sel_back);
- if (hit(key, config.split_sel_newline)) roles.insert(.split_sel_newline);
- if (hit(key, config.merge_sels)) roles.insert(.merge_sels);
- if (hit(key, config.merge_consecutive_sels)) roles.insert(.merge_consecutive_sels);
- if (hit(key, config.trim_sels)) roles.insert(.trim_sels);
- if (hit(key, config.select_regex)) roles.insert(.select_regex);
- if (hit(key, config.split_regex)) roles.insert(.split_regex);
-
- if (hit(key, config.delete)) roles.insert(.delete);
- if (hit(key, config.delete_noyank)) roles.insert(.delete_noyank);
- if (hit(key, config.change)) roles.insert(.change);
- if (hit(key, config.yank)) roles.insert(.yank);
- if (hit(key, config.replace_with_yank)) roles.insert(.replace_with_yank);
- if (hit(key, config.paste_after)) roles.insert(.paste_after);
- if (hit(key, config.paste_before)) roles.insert(.paste_before);
- if (hit(key, config.switch_case)) roles.insert(.switch_case);
- if (hit(key, config.to_lowercase)) roles.insert(.to_lowercase);
- if (hit(key, config.to_uppercase)) roles.insert(.to_uppercase);
- if (hit(key, config.join_lines)) roles.insert(.join_lines);
- if (hit(key, config.indent)) roles.insert(.indent);
- if (hit(key, config.unindent)) roles.insert(.unindent);
- if (hit(key, config.format)) roles.insert(.format);
- if (hit(key, config.increment)) roles.insert(.increment);
- if (hit(key, config.decrement)) roles.insert(.decrement);
- if (hit(key, config.comment_toggle)) roles.insert(.comment_toggle);
- if (hit(key, config.undo)) roles.insert(.undo);
- if (hit(key, config.redo)) roles.insert(.redo);
-
- if (hit(key, config.leader)) roles.insert(.leader);
- if (hit(key, config.command_line)) roles.insert(.command_line);
- if (hit(key, config.pipe_selection)) roles.insert(.pipe_selection);
- if (hit(key, config.search)) roles.insert(.search);
- if (hit(key, config.search_next)) roles.insert(.search_next);
- if (hit(key, config.search_prev)) roles.insert(.search_prev);
+ inline for (std.enums.values(normal_input.Role)) |role| {
+ const name = comptime roleBindingName(role);
+ if (!@hasDecl(config, name))
+ @compileError("normal input role has no config binding: " ++ name);
+ const binding = @field(config, name);
+ const matched = if (@TypeOf(binding) == comptime_int)
+ isPrefix(key, binding)
+ else if (@TypeOf(binding) == []const config.Chord)
+ hit(key, binding)
+ else
+ @compileError("normal input binding has unsupported type: " ++ name);
+ if (matched) out.roles.insert(role);
+ }
return out;
}
@@ -3076,8 +3056,7 @@ pub const CharSel = struct {
/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo
/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only
/// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are
-/// simply not created — raise the number, or swap in an ArrayList, if that
-/// ever matters.
+/// simply not created — raise the bound if that ever matters.
pub const MAX_SELS = 64;
/// One selection range in PANE coordinates: the block-cursor cell and the
@@ -3141,12 +3120,14 @@ pub const File = struct {
/// at least one line. Built and read only through file_pane.lineIndex /
/// nlines / lineStart; see the invalidation contract on lineIndex.
line_starts: []usize = &.{},
- /// one syntax.Syn byte per content byte in the highlighted window
+ /// one tree_sitter_gpa-owned syntax.Syn byte per content byte in the highlighted window
highlights: []u8 = &.{},
highlight_start: usize = 0,
syntax_dirty: bool = true,
- undo: std.ArrayList(FileSnap) = .empty,
- redo: std.ArrayList(FileSnap) = .empty,
+ undo: [UNDO_MAX]FileSnap = undefined,
+ undo_len: usize = 0,
+ redo: [UNDO_MAX]FileSnap = undefined,
+ redo_len: usize = 0,
};
/// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii
@@ -3160,7 +3141,7 @@ pub const Image = struct {
rgba: []u8 = &.{},
iw: usize = 0,
ih: usize = 0,
- /// dump-loaded image bytes (gpa-owned), decoded lazily like a file read
+ /// dump-loaded image bytes (image_gpa-owned), decoded lazily like a file read
raw: []u8 = &.{},
grid: []image.petscii.Cell = &.{},
gw: usize = 0,
@@ -3219,10 +3200,11 @@ const PdfView = if (pdf_enabled) struct {
/// fractional wheel distance while page starts/heights remain integral.
document_scroll_y: f64 = 0,
scroll_to_page_pending: bool = true,
- /// Exactly the visible pages. Entries are removed (and RGBA freed) the
- /// frame a page leaves the viewport, so pixel memory is viewport-bounded
- /// even for documents with pathological page dimensions.
- rasters: std.ArrayList(PdfRaster) = .empty,
+ /// At most PDF_RASTER_MAX visible pages are retained. If a viewport can
+ /// show more pathological tiny pages, later pages are rendered as space
+ /// rather than letting cache memory grow with the document.
+ rasters: [PDF_RASTER_MAX]PdfRaster = undefined,
+ rasters_len: usize = 0,
/// A physical viewport change rebuilds page heights. Preserve the same
/// page-relative reading position instead of reinterpreting old pixels or
/// snapping to the active page's top.
@@ -3364,15 +3346,12 @@ pub const PdfSectionRows = if (pdf_enabled) struct {
return ordinals;
}
- fn appendTitle(
- out: *std.ArrayList(u8),
- gpa: std.mem.Allocator,
- title: ?[]const u8,
- ) !void {
+ fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
const raw = title orelse {
- try out.appendSlice(gpa, "[untitled]");
- return;
+ if (out) |buf| @memcpy(buf[0..10], "[untitled]");
+ return 10;
};
+ var at: usize = 0;
var i: usize = 0;
var wrote = false;
var pending_space = false;
@@ -3396,54 +3375,68 @@ pub const PdfSectionRows = if (pdf_enabled) struct {
if (whitespace_or_control) {
pending_space = wrote;
} else {
- if (pending_space) try out.append(gpa, ' ');
- try out.appendSlice(gpa, raw[i .. i + n]);
+ if (pending_space) {
+ if (out) |buf| buf[at] = ' ';
+ at += 1;
+ }
+ if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
+ at += n;
wrote = true;
pending_space = false;
}
i += n;
}
- if (!wrote) try out.appendSlice(gpa, "[empty title]");
+ if (!wrote) {
+ if (out) |buf| @memcpy(buf[0..13], "[empty title]");
+ return 13;
+ }
+ return at;
}
- pub fn render(
- gpa: std.mem.Allocator,
- path: []const u8,
- entries: []const pdf_impl.OutlineEntry,
- ) ![]u8 {
- // The output pane's directory is the PDF's directory, so a basename
- // resolves to the same document while leaving room for the actual
- // chapter title. An absolute prefix can consume the entire picker.
+ pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf_impl.OutlineEntry) ![]u8 {
const target = std.fs.path.basename(path);
const ordinals = try resolveOrdinals(gpa, entries);
defer gpa.free(ordinals);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
+ var total: usize = 0;
for (entries, 0..) |entry, ordinal| {
const resolved = ordinals[ordinal];
if (resolved == std.math.maxInt(usize)) continue;
const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- switch (destination) {
- .internal => |internal| try out.print(gpa, "{s}:{d}:{d} ", .{
- target,
- internal.page + 1,
- ordinal + 1,
- }),
- .external => |uri| try out.print(gpa, "{s} ", .{uri}),
+ total += switch (destination) {
+ .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| std.fmt.count("{s} ", .{uri}),
.none => unreachable,
- }
- try out.appendNTimes(gpa, ' ', @as(usize, entry.depth) * 2);
- try appendTitle(&out, gpa, entry.title);
- try out.append(gpa, '\n');
+ };
+ total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
}
- return out.toOwnedSlice(gpa);
+ const out = try gpa.alloc(u8, total);
+ errdefer gpa.free(out);
+ var at: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ const prefix = switch (destination) {
+ .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
+ .none => unreachable,
+ };
+ at += prefix.len;
+ const indent = @as(usize, entry.depth) * 2;
+ @memset(out[at..][0..indent], ' ');
+ at += indent;
+ at += cleanTitle(out[at..], entry.title);
+ out[at] = '\n';
+ at += 1;
+ }
+ return out;
}
} else struct {};
const PdfSlot = if (pdf_enabled) ?PdfView else void;
fn hasPdf(pane: *const Pane) bool {
- return if (comptime pdf_enabled) pane.pdf != null else false;
+ return pdf_enabled and pane.kind == .pdf;
}
fn hasPdfSelection(pane: *const Pane) bool {
@@ -3456,6 +3449,12 @@ fn hasPdfSelection(pane: *const Pane) bool {
test "feature-off PDF pane state is zero-sized" {
if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot));
}
+pub const PaneKind = enum { terminal, file, image, pdf };
+const Prompt = union(enum) {
+ none,
+ search: u16,
+ pipe: u16,
+};
pub const Pane = struct {
vt: ghostty_vt.Terminal,
@@ -3466,6 +3465,9 @@ pub const Pane = struct {
/// ensureCursorVisible, lastNavRow — can build it. The alternative was
/// threading an allocator through ensureCursorVisible's 33 call sites.
gpa: std.mem.Allocator,
+ /// Active payload discriminator. Constructors set this with the matching
+ /// file/image/PDF payload; terminal panes carry none of those payloads.
+ kind: PaneKind = .terminal,
/// WHICH pane this is, for anything that outlives the pane: slots are
/// REUSED (freeSlot hands back the lowest free one), so a remembered id
/// alone can silently come to mean an unrelated pane. Handed out by
@@ -3514,21 +3516,9 @@ pub const Pane = struct {
find_ch: u21 = 0,
/// Compact storage for normal_input.State's typed prefix.
pending: u21 = 0,
- /// `/` search and the Find builtin: the pattern is typed into the tag tail
- /// (no popup); search_at is the tail length before the marker was appended
- /// (submit/cancel restore it, and the marker itself says WHICH search this
- /// is). Enter fills the output buffer in pane search_pane with one result
- /// per row; n/N walk it, search_row being the row they last landed on
- /// (null = fresh results, nothing stepped to yet). WHICH command filled
- /// that buffer is the BUFFER's own record, never a second field here —
- /// output_pane.resultsFrom is how `]d` asks.
- search_edit: bool = false,
- search_at: u16 = 0,
- /// `|` reuses the visible tag-tail input but submits a shell-filter
- /// request rather than a search. pipe_at is a tail byte offset exactly
- /// like search_at; Enter/Esc restore the tail to it.
- pipe_edit: bool = false,
- pipe_at: u16 = 0,
+ /// Tag-tail input state. The tag text is presentation; this tag carries
+ /// which operation owns it and the tail offset restored on submit/cancel.
+ prompt: Prompt = .none,
search_pane: ?usize = null,
search_row: ?usize = null,
/// The selection an `s`/`S` input was armed on, as gap offsets over the
@@ -3538,7 +3528,7 @@ pub const Pane = struct {
/// a pattern one character at a time land on the same answer as pasting it
/// whole, and what makes Esc a plain restore with nothing else to undo.
/// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one
- /// place search_edit is ever cleared, clears it too.
+ /// place the prompt is cleared, clears it too.
sel_snap: [MAX_SELS]modal.HxRange = undefined,
nsel_snap: u8 = 0,
sel_snap_pri: u8 = 0,
@@ -3546,9 +3536,10 @@ pub const Pane = struct {
/// (you picked those matches), but restoring must not silently promote
/// motion residue into something the acme chords will act on
sel_snap_expl: bool = false,
- /// the editable tag tail: seeded with the default builtins on first touch;
- /// edited in place by a modal one-line editor sharing the pane's mode
- tag_tail: std.ArrayList(u8) = .empty,
+ /// the editable tag tail: a bounded one-line command buffer. Input that
+ /// does not fit is refused atomically.
+ tag_tail: [TAG_TAIL_CAP]u8 = undefined,
+ tag_tail_len: usize = 0,
tag_init: bool = false,
tag_edit: bool = false,
tag_sel: bool = false,
@@ -3563,8 +3554,10 @@ pub const Pane = struct {
/// does nothing left of it.
tag_col: u16 = 0,
tag_anchor: u16 = 0,
- ed_undo: std.ArrayList(EditSnap) = .empty,
- ed_redo: std.ArrayList(EditSnap) = .empty,
+ ed_undo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_undo_len: usize = 0,
+ ed_redo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_redo_len: usize = 0,
/// live working directory, shell-reported (setCwd); shown in the tag and
/// the base for look-resolution of relative paths.
cwd: [1024]u8 = undefined,
@@ -3606,8 +3599,12 @@ pub const Pane = struct {
sel: [3]Sel = @splat(.{}),
/// terminals only: the typed-text buffer standing in for shell rows
ovl: ?Ovl = null,
- /// every raw pty byte, in order — the dump/replay record.
- tty_stream: std.ArrayList(u8) = .empty,
+ /// every raw pty byte, in order — a bounded dump/replay ring. Once full,
+ /// new output evicts the oldest bytes while the live terminal still sees
+ /// every byte.
+ tty_stream: [TTY_REPLAY_CAP]u8 = undefined,
+ tty_stream_head: usize = 0,
+ tty_stream_len: usize = 0,
/// query replies ghostty computed (DSR, DA, kitty); the stream handler has
/// no path to the effect queue, so they land here and sync() drains them
/// into write effects. Bounded: replies are tiny escape sequences.
@@ -3622,12 +3619,95 @@ pub const Pane = struct {
msg: [256]u8 = undefined,
msg_len: u16 = 0,
+ fn tagSlice(p: *const Pane) []const u8 {
+ return p.tag_tail[0..p.tag_tail_len];
+ }
+ fn promptAt(p: *const Pane) ?u16 {
+ return switch (p.prompt) {
+ .none => null,
+ .search, .pipe => |at| at,
+ };
+ }
+
+ fn hasSearchPrompt(p: *const Pane) bool {
+ return switch (p.prompt) {
+ .search => true,
+ else => false,
+ };
+ }
+
+ fn hasPipePrompt(p: *const Pane) bool {
+ return switch (p.prompt) {
+ .pipe => true,
+ else => false,
+ };
+ }
+
+ fn appendTag(p: *Pane, text: []const u8) bool {
+ if (text.len > p.tag_tail.len - p.tag_tail_len) return false;
+ @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text);
+ p.tag_tail_len += text.len;
+ return true;
+ }
+
+ fn insertTagByte(p: *Pane, at: usize, byte: u8) bool {
+ if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false;
+ std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]);
+ p.tag_tail[at] = byte;
+ p.tag_tail_len += 1;
+ return true;
+ }
+
+ fn removeTagByte(p: *Pane, at: usize) void {
+ if (at >= p.tag_tail_len) return;
+ std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]);
+ p.tag_tail_len -= 1;
+ }
+
+ fn appendTtyReplay(p: *Pane, bytes: []const u8) void {
+ if (bytes.len >= p.tty_stream.len) {
+ const kept = bytes[bytes.len - p.tty_stream.len ..];
+ @memcpy(p.tty_stream[0..], kept);
+ p.tty_stream_head = 0;
+ p.tty_stream_len = p.tty_stream.len;
+ return;
+ }
+ const overflow = bytes.len -| (p.tty_stream.len - p.tty_stream_len);
+ p.tty_stream_head = (p.tty_stream_head + overflow) % p.tty_stream.len;
+ p.tty_stream_len -= overflow;
+ const tail = (p.tty_stream_head + p.tty_stream_len) % p.tty_stream.len;
+ const first = @min(bytes.len, p.tty_stream.len - tail);
+ @memcpy(p.tty_stream[tail..][0..first], bytes[0..first]);
+ @memcpy(p.tty_stream[0 .. bytes.len - first], bytes[first..]);
+ p.tty_stream_len += bytes.len;
+ }
+
+ fn ttyReplayBytes(p: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
+ if (p.tty_stream_len == 0) return &.{};
+ if (p.tty_stream_head + p.tty_stream_len <= p.tty_stream.len)
+ return p.tty_stream[p.tty_stream_head..][0..p.tty_stream_len];
+ const out = try allocator.alloc(u8, p.tty_stream_len);
+ const first = p.tty_stream.len - p.tty_stream_head;
+ @memcpy(out[0..first], p.tty_stream[p.tty_stream_head..]);
+ @memcpy(out[first..], p.tty_stream[0 .. p.tty_stream_len - first]);
+ return out;
+ }
+
pub fn cwdSlice(p: *const Pane) []const u8 {
return p.cwd[0..p.cwd_len];
}
+ fn payloadValid(p: *const Pane) bool {
+ const no_pdf = if (comptime pdf_enabled) p.pdf == null else true;
+ return switch (p.kind) {
+ .terminal => p.file == null and p.image == null and no_pdf,
+ .file => p.file != null and p.image == null and no_pdf,
+ .image => p.file == null and p.image != null and no_pdf,
+ .pdf => if (comptime pdf_enabled) p.file == null and p.image == null and p.pdf != null else false,
+ };
+ }
pub fn isTerminal(pane: *const Pane) bool {
- return pane.file == null and pane.image == null and !hasPdf(pane);
+ return pane.kind == .terminal;
}
pub fn pdfPath(pane: *const Pane) ?[]const u8 {
@@ -3965,6 +4045,9 @@ pub const Options = struct {
/// deterministic; when present, each line is dispatched as a builtin
/// before init returns and therefore before any frontend can render.
startup_config: ?[]const u8 = null,
+ image_allocator: ?std.mem.Allocator = null,
+ pdf_allocator: ?std.mem.Allocator = null,
+ tree_sitter_allocator: ?std.mem.Allocator = null,
/// Initial grid. Shell contract: for LIVE sessions leave these at the
/// defaults and deliver the real size as the first resize EVENT — the core
/// defers each shell's greeting until after a resize (so `ls` wraps to the
@@ -4007,6 +4090,9 @@ const PendingPipe = struct {
pub const Pardes = struct {
gpa: std.mem.Allocator,
+ image_gpa: std.mem.Allocator,
+ pdf_gpa: std.mem.Allocator,
+ tree_sitter_gpa: std.mem.Allocator,
opts: Options,
screen_w: u16,
screen_h: u16,
@@ -4141,11 +4227,12 @@ pub const Pardes = struct {
pipe_seq: u32 = 0,
pipe_wait: ?PendingPipe = null,
- /// pending effects, drained by the shell after each update. Growable: one
- /// update can emit unbounded writes (a big selection executed is chunked
- /// into 64-byte write effects) — a fixed ring overflows and panics.
- effects: std.ArrayList(Effect) = .empty,
+ /// Pending effects, drained by the shell after each update. The bounded
+ /// ring preserves byte order; once full, later effects are refused so no
+ /// already-queued write can be reordered or silently evicted.
+ effects: [EFFECT_CAP]Effect = undefined,
effects_head: usize = 0,
+ effects_len: usize = 0,
/// modal yank register (gpa-owned); a yank also mirrors out to the system
/// clipboard via the set_clipboard effect.
@@ -4165,8 +4252,21 @@ pub const Pardes = struct {
scratch: std.heap.ArenaAllocator,
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ const image_gpa = opts.image_allocator orelse gpa;
+ const pdf_gpa = opts.pdf_allocator orelse gpa;
+ const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa;
const p = try gpa.create(Pardes);
- p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ p.* = .{
+ .gpa = gpa,
+ .image_gpa = image_gpa,
+ .pdf_gpa = pdf_gpa,
+ .tree_sitter_gpa = tree_sitter_gpa,
+ .opts = opts,
+ .screen_w = opts.cols,
+ .screen_h = opts.rows,
+ .scratch = .init(gpa),
+ };
+ errdefer p.deinit();
if (opts.file) |path| {
// FILE argv boot: the doc alone, filling the window. Naming a file
// is asking to READ it, not to be handed a shell you did not ask
@@ -4226,7 +4326,6 @@ pub const Pardes = struct {
if (p.last_dump) |d| gpa.free(d);
if (p.chord_arg) |a| gpa.free(a);
if (p.pipe_wait) |*wait| wait.deinit(gpa);
- p.effects.deinit(gpa);
p.scratch.deinit();
gpa.free(p.surface.cells);
if (p.surface.images.len > 0) gpa.free(p.surface.images);
@@ -4257,29 +4356,29 @@ pub const Pardes = struct {
}
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
+ std.debug.assert(pane.payloadValid());
if (pane.image) |*iv| {
- p.gpa.free(iv.path);
- if (iv.rgba.len > 0) p.gpa.free(iv.rgba);
- if (iv.raw.len > 0) p.gpa.free(iv.raw);
- if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ p.image_gpa.free(iv.path);
+ if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba);
+ if (iv.raw.len > 0) p.image_gpa.free(iv.raw);
+ if (iv.grid.len > 0) p.image_gpa.free(iv.grid);
}
if (comptime pdf_enabled) {
if (pane.pdf) |*pv| {
- p.gpa.free(pv.path);
- for (pv.rasters.items) |raster| if (raster.rgba.len > 0)
- p.gpa.free(raster.rgba);
- pv.rasters.deinit(p.gpa);
- p.gpa.free(pv.page_sizes);
- p.gpa.free(pv.page_starts);
- p.gpa.free(pv.page_heights);
+ p.pdf_gpa.free(pv.path);
+ for (pv.rasters[0..pv.rasters_len]) |raster| if (raster.rgba.len > 0)
+ p.pdf_gpa.free(raster.rgba);
+ p.pdf_gpa.free(pv.page_sizes);
+ p.pdf_gpa.free(pv.page_starts);
+ p.pdf_gpa.free(pv.page_heights);
// `rgba` aliases the active raster for legacy geometry state;
// its owner was released above.
- if (pv.text.len > 0) p.gpa.free(pv.text);
- if (pv.search_query.len > 0) p.gpa.free(pv.search_query);
- if (pv.search_results) |*results| results.deinit(p.gpa);
- if (pv.selection) |*selection| selection.deinit(p.gpa);
- if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text);
- if (pv.outline) |*outline| outline.deinit(p.gpa);
+ if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
+ if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query);
+ if (pv.search_results) |*results| results.deinit(p.pdf_gpa);
+ if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
+ if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text);
+ if (pv.outline) |*outline| outline.deinit(p.pdf_gpa);
pv.document.deinit();
}
}
@@ -4295,21 +4394,15 @@ pub const Pardes = struct {
p.gpa.free(f.path);
p.gpa.free(f.content);
if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
- if (f.highlights.len > 0) p.gpa.free(f.highlights);
- for (f.undo.items) |s| p.gpa.free(s.content);
- f.undo.deinit(p.gpa);
- for (f.redo.items) |s| p.gpa.free(s.content);
- f.redo.deinit(p.gpa);
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ for (f.undo[0..f.undo_len]) |s| p.gpa.free(s.content);
+ for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
}
if (pane.ovl) |o| p.gpa.free(o.text);
- pane.tag_tail.deinit(p.gpa);
- for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.ed_undo.deinit(p.gpa);
- for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.ed_redo.deinit(p.gpa);
+ for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
+ for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
pane.stream.deinit();
pane.vt.deinit(p.gpa);
- pane.tty_stream.deinit(p.gpa);
p.gpa.destroy(pane);
}
@@ -4324,6 +4417,7 @@ pub const Pardes = struct {
std.debug.assert(p.panes[id] == null);
if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256)
const pane = try p.gpa.create(Pane);
+ errdefer p.gpa.destroy(pane);
pane.* = .{
.vt = try ghostty_vt.Terminal.init(p.gpa, .{
.cols = p.screen_w,
@@ -4352,6 +4446,7 @@ pub const Pardes = struct {
pub fn newDocPane(p: *Pardes, id: usize) !*Pane {
std.debug.assert(p.panes[id] == null);
const pane = try p.gpa.create(Pane);
+ errdefer p.gpa.destroy(pane);
pane.* = .{
.vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
@@ -4415,6 +4510,7 @@ pub const Pardes = struct {
return false;
};
pane.file = .{ .path = path_copy, .content = content };
+ pane.kind = .file;
pane.cur_pinned = true;
// sf was captured from SOURCE, not active. Nothing above this point
@@ -4450,19 +4546,27 @@ pub const Pardes = struct {
pane.msg_len = @intCast(@min(text.len, pane.msg.len));
@memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]);
}
+ pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
+ var buf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
+ p.setMessage(id, text);
+ }
pub fn emit(p: *Pardes, e: Effect) void {
- p.effects.append(p.gpa, e) catch {};
+ if (p.effects_len == p.effects.len) return;
+ const tail = (p.effects_head + p.effects_len) % p.effects.len;
+ p.effects[tail] = e;
+ p.effects_len += 1;
}
pub fn nextEffect(p: *Pardes) ?Effect {
- if (p.effects_head == p.effects.items.len) {
- p.effects.clearRetainingCapacity();
+ if (p.effects_len == 0) {
p.effects_head = 0;
return null;
}
- const e = p.effects.items[p.effects_head];
- p.effects_head += 1;
+ const e = p.effects[p.effects_head];
+ p.effects_head = (p.effects_head + 1) % p.effects.len;
+ p.effects_len -= 1;
return e;
}
@@ -4519,7 +4623,7 @@ pub const Pardes = struct {
},
.output => |o| {
const pane = p.panes[o.pane] orelse return;
- pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {};
+ pane.appendTtyReplay(o.bytes);
pane.stream.nextSlice(o.bytes);
// follow new output to the bottom only in tty mode; in
// normal/insert the user is reading scrollback and the view
@@ -4619,7 +4723,7 @@ pub const Pardes = struct {
/// the editable tail: the user's edited buffer once touched, else defaults
/// (a buffer with nothing to Save gets the plain tail — the table decides)
fn curTail(pane: *Pane) []const u8 {
- if (pane.tag_init) return pane.tag_tail.items;
+ if (pane.tag_init) return pane.tagSlice();
if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail;
return pane_tail;
}
@@ -4705,7 +4809,7 @@ pub const Pardes = struct {
(if (output_pane.fileTraits(f.output).saves) file_pane_tail else pane_tail)
else
pane_tail;
- const laid = if (q.tag_init) q.tag_tail.items else words;
+ const laid = if (q.tag_init) q.tagSlice() else words;
const lead = laid.len - std.mem.trimStart(u8, laid, " ").len;
const q_end = (p.tagPrefix(q) catch continue).len + lead + std.mem.trimStart(u8, words, " ").len;
end = @max(end, @min(q_end, tw));
@@ -4734,8 +4838,11 @@ pub const Pardes = struct {
if (pane.tag_init) return;
const tail = curTail(pane);
const prefix_len = (p.tagPrefix(pane) catch return).len;
- pane.tag_tail.appendNTimes(p.gpa, ' ', p.tagGap(pane, prefix_len + tail.len)) catch return;
- pane.tag_tail.appendSlice(p.gpa, tail) catch return;
+ const gap = p.tagGap(pane, prefix_len + tail.len);
+ if (gap + tail.len > pane.tag_tail.len) return;
+ @memset(pane.tag_tail[0..gap], ' ');
+ @memcpy(pane.tag_tail[gap..][0..tail.len], tail);
+ pane.tag_tail_len = gap + tail.len;
pane.tag_init = true;
}
@@ -4747,20 +4854,21 @@ pub const Pardes = struct {
fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len);
p.seedTail(pane);
+ if (!pane.tag_init) return;
if (!pane.tag_edit) pane.tag_mode = pane.mode;
pane.tag_edit = true;
pane.tag_sel = false;
// a one-line tag has no use for normal mode: always insert
pane.mode = .insert;
pane.pending = 0;
- const end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len));
+ const end = edit0 + @as(i32, @intCast(pane.tag_tail_len));
// Past the gap that right-aligns the builtins, not at the first
// editable column: `:` promises the tail's START, and the start of a
// run of layout spaces is not it. Landing there would cost `:w` its
// second keystroke — w would select the whitespace and execute nothing
// — and `:w` being the same two keys every time is the whole point of
// the door. The spaces stay editable; h and Left still walk into them.
- const tail = pane.tag_tail.items;
+ const tail = pane.tagSlice();
const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len);
pane.tag_col = @intCast(if (col < 0) @min(edit0 + lead, end) else std.math.clamp(col, 0, end));
}
@@ -4774,8 +4882,7 @@ pub const Pardes = struct {
} else pane.mode = .normal;
pane.tag_edit = false;
pane.tag_sel = false;
- pane.search_edit = false; // an abandoned search input stays as tag text
- pane.pipe_edit = false;
+ pane.prompt = .none; // an abandoned prompt stays as tag text
pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed
}
@@ -4819,19 +4926,22 @@ pub const Pardes = struct {
// (that is how the path selects), but every edit below is measured from
// the first EDITABLE column and simply does nothing to the left of it.
const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len);
- const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail.items.len));
+ const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len));
if (key.text.len > 0) {
if (pane.tag_col < edit0) return; // typing inside the path: inert
+ var insert_len: usize = 0;
+ for (key.text) |ch| insert_len += @intFromBool(ch != '\n' and ch != '\r');
+ if (insert_len > pane.tag_tail.len - pane.tag_tail_len) return;
for (key.text) |ch| {
if (ch == '\n' or ch == '\r') continue; // one-line tag
- pane.tag_tail.insert(p.gpa, pane.tag_col - edit0, ch) catch return;
+ if (!pane.insertTagByte(pane.tag_col - edit0, ch)) return;
pane.tag_col += 1;
}
return;
}
switch (key.cp) {
Key.backspace => if (pane.tag_col > edit0) {
- _ = pane.tag_tail.orderedRemove(pane.tag_col - edit0 - 1);
+ pane.removeTagByte(pane.tag_col - edit0 - 1);
pane.tag_col -= 1;
},
Key.left => if (pane.tag_col > 0) {
@@ -5055,19 +5165,35 @@ pub const Pardes = struct {
const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
const text = try p.paneText(pane);
- var out: std.ArrayList(u8) = .empty;
+ var total: usize = 0;
+ var selected: usize = 0;
+ var count_it = std.mem.splitAny(u8, text, "\n");
+ var count_row: i32 = 0;
+ while (count_it.next()) |line| : (count_row += 1) {
+ if (count_row < r0 or count_row > r1) continue;
+ const b0 = @min(c0, line.len);
+ const b1 = @min(c1 + 1, line.len);
+ total += b1 - b0 + @intFromBool(selected > 0);
+ selected += 1;
+ }
+ const out = try arena.alloc(u8, total);
+ var at: usize = 0;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
var first = true;
while (it.next()) |line| : (v += 1) {
if (v < r0 or v > r1) continue;
- if (!first) try out.append(arena, '\n');
+ if (!first) {
+ out[at] = '\n';
+ at += 1;
+ }
first = false;
const b0 = @min(c0, line.len);
const b1 = @min(c1 + 1, line.len);
- try out.appendSlice(arena, line[b0..b1]);
+ @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
+ at += b1 - b0;
}
- return out.items;
+ return out;
}
/// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end
@@ -5246,9 +5372,10 @@ pub const Pardes = struct {
// `|` owns the same visible one-line tag input as search, but Enter
// snapshots an asynchronous shell filter. Escape is a pure cancel:
// restore the old tail and never emit a request.
- if (pane.pipe_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ if (pane.hasPipePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ const prompt_at = pane.promptAt().?;
if (hit(key, config.search_submit)) p.submitPipe(p.active);
- pane.tag_tail.shrinkRetainingCapacity(@min(pane.pipe_at, pane.tag_tail.items.len));
+ pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len);
exitTagEdit(pane);
pane.mode = .normal;
pane.pending = 0;
@@ -5256,15 +5383,13 @@ pub const Pardes = struct {
}
// a search input in flight (`/` or Find): Enter searches, Esc abandons;
// both restore the tag tail and hand focus back to the body.
- if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ const prompt_at = pane.promptAt().?;
if (hit(key, config.search_submit))
p.submitSearch(p.active)
else if (selRegexArmed(pane)) |_|
- // Esc on an `s`/`S` preview: back to the selection it opened
- // on. The empty pattern applies nothing, so this IS the
- // snapshot — one path, not a second restore.
p.applySelRegex(pane, "", false);
- pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len));
+ pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len);
exitTagEdit(pane);
pane.mode = .normal;
pane.pending = 0;
@@ -5377,7 +5502,11 @@ pub const Pardes = struct {
p.leader_on = false; // only "still a prefix" below re-arms it
if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return;
const c: u8 = @intCast(key.cp);
- if (c == config.leader_help) return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]);
+ if (c == config.leader_help) {
+ output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err|
+ p.reportError(p.active, "help", err);
+ return;
+ }
if (p.leader_n >= p.leader_keys.len) return;
p.leader_keys[p.leader_n] = c;
p.leader_n += 1;
@@ -5600,7 +5729,7 @@ pub const Pardes = struct {
const pv = &(pane.pdf orelse return error.NotPdf);
if (!pv.text_tried) {
pv.text_tried = true;
- pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{};
+ pv.text = pv.document.pageText(p.pdf_gpa, pv.page) catch &.{};
}
return pv.text;
}
@@ -5614,10 +5743,9 @@ pub const Pardes = struct {
pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
const arena = p.scratch.allocator();
if (pane.file) |*f| {
- // straight off the line index: exact size (no ArrayList regrowth
- // copying a 300k-entry table forward) and no second scan for the
- // newlines the index already found. Identical to splitting the
- // content — a file's line count is its newline count plus one.
+ // Straight off the line index: exact size, no repeated growth or
+ // second scan for newlines the index already found. A file's line
+ // count is its newline count plus one.
const idx = try file_pane.lineIndex(pane.gpa, f);
const ls = try arena.alloc([]const u8, idx.len);
for (idx, 0..) |s, i| {
@@ -5628,28 +5756,37 @@ pub const Pardes = struct {
}
if (hasPdf(pane)) {
const text = try p.ensurePdfText(pane);
- var ls: std.ArrayList([]const u8) = .empty;
+ const count = std.mem.count(u8, text, "\n") + 1;
+ const ls = try arena.alloc([]const u8, count);
var it = std.mem.splitScalar(u8, text, '\n');
- while (it.next()) |line| try ls.append(arena, line);
- return .{ .lines = ls.items, .row0 = 0 };
+ var n: usize = 0;
+ while (it.next()) |line| : (n += 1) ls[n] = line;
+ return .{ .lines = ls, .row0 = 0 };
}
const rows = try term_pane.shellRows(p, pane);
// the buffer may sit below the dump's last row (the grid's trailing
// blanks are trimmed off), so run the loop to whichever ends later
var last = rows.len;
if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows))));
- var ls: std.ArrayList([]const u8) = .empty;
+ var count = last;
+ if (pane.ovl) |o| {
+ if (o.row >= 0 and @as(usize, @intCast(o.row)) < last)
+ count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + modal.lineCount(o.text);
+ }
+ const ls = try arena.alloc([]const u8, count);
+ var n: usize = 0;
var g: usize = 0;
while (g < last) : (g += 1) {
if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) {
var bit = std.mem.splitScalar(u8, o.text, '\n');
- while (bit.next()) |ln| try ls.append(arena, ln);
+ while (bit.next()) |ln| : (n += 1) ls[n] = ln;
g += @intCast(o.rows - 1);
continue;
};
- try ls.append(arena, if (g < rows.len) rows[g] else "");
+ ls[n] = if (g < rows.len) rows[g] else "";
+ n += 1;
}
- return .{ .lines = ls.items, .row0 = 0 };
+ return .{ .lines = ls[0..n], .row0 = 0 };
}
fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
@@ -5677,12 +5814,19 @@ pub const Pardes = struct {
if (pane.file) |f| return f.content;
if (hasPdf(pane)) return p.ensurePdfText(pane);
const arena = p.scratch.allocator();
- var buf: std.ArrayList(u8) = .empty;
+ var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0;
+ for (pl.lines) |ln| total += ln.len;
+ const buf = try arena.alloc(u8, total);
+ var at: usize = 0;
for (pl.lines, 0..) |ln, i| {
- if (i > 0) try buf.append(arena, '\n');
- try buf.appendSlice(arena, ln);
+ if (i > 0) {
+ buf[at] = '\n';
+ at += 1;
+ }
+ @memcpy(buf[at..][0..ln.len], ln);
+ at += ln.len;
}
- return buf.items;
+ return buf;
}
/// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when
@@ -6095,9 +6239,13 @@ pub const Pardes = struct {
/// it is — read back off the MARKER, exactly the way submitSearch decides
/// which search is running. Null for `/`, Find, Grep and Rename.
fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
- if (!pane.search_edit or pane.nsel_snap == 0) return null;
- const tail = pane.tag_tail.items;
- const armed = tail[@min(pane.search_at, tail.len)..];
+ const prompt_at = switch (pane.prompt) {
+ .search => |at| at,
+ else => return null,
+ };
+ if (pane.nsel_snap == 0) return null;
+ const tail = pane.tagSlice();
+ const armed = tail[@min(prompt_at, tail.len)..];
const split = std.mem.startsWith(u8, armed, config.split_marker);
if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null;
@@ -6890,14 +7038,15 @@ pub const Pardes = struct {
const f = pane.file orelse return;
if (!output_pane.fileTraits(f.output).saves) return;
p.seedTail(pane);
- pane.pipe_at = @intCast(pane.tag_tail.items.len);
- pane.tag_tail.appendSlice(p.gpa, config.pipe_marker) catch return;
- pane.pipe_edit = true;
+ if (!pane.tag_init) return;
+ const prompt_at: u16 = @intCast(pane.tag_tail_len);
+ if (!pane.appendTag(config.pipe_marker)) return;
+ pane.prompt = .{ .pipe = prompt_at };
pane.tag_edit = true;
pane.tag_sel = false;
pane.mode = .insert;
pane.pending = 0;
- pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
+ pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
}
/// Snapshot command/cwd/ranges/selection bytes before emitting the id-only
@@ -6907,8 +7056,8 @@ pub const Pardes = struct {
const pane = p.panes[id] orelse return;
const f = pane.file orelse return;
if (!output_pane.fileTraits(f.output).saves) return;
- const tail = pane.tag_tail.items;
- const armed = tail[@min(pane.pipe_at, tail.len)..];
+ const tail = pane.tagSlice();
+ const armed = tail[@min(pane.promptAt() orelse return, tail.len)..];
if (!std.mem.startsWith(u8, armed, config.pipe_marker)) return;
const command = armed[config.pipe_marker.len..];
if (command.len == 0) return;
@@ -7039,7 +7188,7 @@ pub const Pardes = struct {
return pane.cwdSlice();
}
- const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null };
+ const PdfSearchResult = struct { bytes: usize = 0, rows: usize = 0, anchor: ?usize = null };
/// Ask MuPDF for each page's logical hits, so the result list and the
/// quads used to highlight/reveal can never disagree. Locations spell
@@ -7052,11 +7201,11 @@ pub const Pardes = struct {
pane: *Pane,
pat: []const u8,
start: SearchStart,
- out: *std.ArrayList(u8),
- ) PdfSearchResult {
+ out: []u8,
+ ) !PdfSearchResult {
if (comptime !pdf_enabled) return .{};
const pv = &(pane.pdf orelse return .{});
- p.setPdfSearchQuery(pv, pat);
+ try p.setPdfSearchQuery(pv, pat);
const arena = p.scratch.allocator();
const shown = std.fs.path.basename(pv.path);
var result: PdfSearchResult = .{};
@@ -7067,8 +7216,8 @@ pub const Pardes = struct {
const snippet = pat[0..snippet_len];
for (0..pv.page_count) |page| {
if (result.rows >= max_hits) break;
- var found = pv.document.search(p.gpa, page, pat) catch continue;
- defer found.deinit(p.gpa);
+ var found = try pv.document.search(p.pdf_gpa, page, pat);
+ defer found.deinit(p.pdf_gpa);
// A native right-click selection identifies the occurrence the
// user is already on. Compare hit centers against its oriented
@@ -7088,13 +7237,15 @@ pub const Pardes = struct {
for (0..found.hit_count) |hit_index| {
if (result.rows >= max_hits) break;
+ const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ shown, page + 1, hit_index + 1, snippet,
+ });
+ if (row.len > out.len - result.bytes) return result;
if (start == .cursor and
(page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?)))
result.anchor = result.rows;
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- shown, page + 1, hit_index + 1, snippet,
- }) catch return result;
- out.appendSlice(arena, row) catch return result;
+ @memcpy(out[result.bytes..][0..row.len], row);
+ result.bytes += row.len;
result.rows += 1;
}
}
@@ -7106,16 +7257,16 @@ pub const Pardes = struct {
/// typing, nothing that disappears. Enter/Esc are intercepted in handleKey.
pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void {
p.seedTail(pane);
- pane.search_at = @intCast(pane.tag_tail.items.len);
- pane.tag_tail.appendSlice(p.gpa, marker) catch return;
- pane.search_edit = true;
+ if (!pane.tag_init) return;
+ const prompt_at: u16 = @intCast(pane.tag_tail_len);
+ if (!pane.appendTag(marker)) return;
+ pane.prompt = .{ .search = prompt_at };
pane.tag_edit = true;
pane.tag_sel = false;
pane.mode = .insert;
pane.pending = 0;
- // tag_col is a RENDERED-tag column, so the end of the tail is past the
- // prefix (search_at stays a tail offset — it slices tag_tail)
- pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
+ // tag_col is a rendered-tag column; the prompt offset slices tag_tail.
+ pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
}
/// Enter on an armed input: the pattern is everything past the marker's
@@ -7129,8 +7280,8 @@ pub const Pardes = struct {
// Enter re-runs the final pattern so a submit is one code path with
// the preview and cannot disagree with what is on screen.
if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split);
- const tail = pane.tag_tail.items;
- const armed = tail[@min(pane.search_at, tail.len)..];
+ const tail = pane.tagSlice();
+ const armed = tail[@min(pane.promptAt() orelse return, tail.len)..];
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
@@ -7140,7 +7291,7 @@ pub const Pardes = struct {
.grep
else
.text;
- p.runSearch(id, armed[slash + 1 ..], kind, .top);
+ p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| p.reportError(id, "search", err);
}
/// Fill this pane's results buffer with everything matching `pat_raw`
@@ -7167,7 +7318,7 @@ pub const Pardes = struct {
/// there and the first step goes to the next one (acme's button-3 walking a
/// word). `/`, Find and Grep point at nothing, so their list is walked from
/// the top, which is also the only place a list of OTHER files could start.
- pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) void {
+ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void {
const pane = p.panes[id] orelse return;
const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
if (pat.len == 0) return;
@@ -7175,7 +7326,8 @@ pub const Pardes = struct {
// where the pane lives: a file's directory, a shell's cwd — the walk
// root, and the directory the results buffer is named in.
const dir = paneDir(pane);
- var out: std.ArrayList(u8) = .empty;
+ const out = try arena.alloc(u8, look.search_max_output_bytes);
+ var out_len: usize = 0;
var nrows: usize = 0;
var anchor: ?usize = null;
if (kind == .grep) {
@@ -7207,13 +7359,17 @@ pub const Pardes = struct {
roots[nroots] = d;
nroots += 1;
}
- for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out);
- } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) {
- const found = p.searchPdf(pane, pat, start, &out);
+ for (roots[0..nroots]) |r|
+ out_len += try look.grep(arena, p.gpa, r, dir, pat, out[out_len..]);
+ } else if (kind == .find) {
+ out_len = try look.find(arena, dir, pat, out);
+ } else if (hasPdf(pane)) {
+ const found = try p.searchPdf(pane, pat, start, out);
+ out_len = found.bytes;
nrows = found.rows;
anchor = found.anchor;
} else {
- const pl = p.paneCursorLines(pane) catch return;
+ const pl = try p.paneCursorLines(pane);
// a real file names itself; a terminal or an output buffer has no path
const has_path = if (pane.file) |f| f.output == null else false;
var idbuf: [16]u8 = undefined;
@@ -7222,7 +7378,7 @@ pub const Pardes = struct {
const loc: []const u8 = if (has_path)
std.fs.path.basename(pane.file.?.path)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong;
// the hit at or before the cursor is the one you are ON, so arming
// there makes the first step land on the NEXT one: a click on the
// second `foo` goes to the third, not back to the first.
@@ -7230,17 +7386,17 @@ pub const Pardes = struct {
const cc: usize = @intCast(@max(0, pane.cur_col));
for (pl.lines, 0..) |ln, i| {
const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
+ const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
+ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"),
+ });
+ if (row.len > out.len - out_len) break;
if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows;
+ @memcpy(out[out_len..][0..row.len], row);
+ out_len += row.len;
nrows += 1;
- // the row names the MATCH's span, not just its first cell, so
- // n/N land ON the hit with it selected (config.range_sep)
- const row = std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{
- loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"),
- }) catch return;
- out.appendSlice(arena, row) catch return;
}
}
- const content = p.gpa.dupe(u8, out.items) catch return;
+ const content = try p.gpa.dupe(u8, out[0..out_len]);
// the buffer records WHICH search filled it, pattern and all: Find and
// Grep are builtins (words you can execute), the bare `/` is a key
const from: output_pane.Origin = switch (kind) {
@@ -7253,7 +7409,7 @@ pub const Pardes = struct {
// below this pane. Everything about landing the rows — which open
// buffer counts as this same search, keeping a refill's place, opening
// fresh when there is none — is output_pane.fillResults.
- output_pane.fillResults(p, id, dir, from, pat, content, anchor);
+ try output_pane.fillResults(p, id, dir, from, pat, content, anchor);
}
/// n/N: step to the next/previous row of this pane's results buffer and
@@ -7398,7 +7554,8 @@ pub const Pardes = struct {
// Landing the rows is runSearch's path exactly, keyed on the KIND
// rather than the argument (fillResults reads that off the origin).
// Why the refill is not optional here: docs/lsp.md.
- output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null);
+ output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err|
+ p.reportError(w.pane, "language response", err);
}
/// n/N on a terminal pane: a MOTION over lookable tokens. Select the
@@ -7458,17 +7615,7 @@ pub const Pardes = struct {
const EditText = struct { text: []u8, row0: i32 };
- /// The pane's editable text and the absolute surface row its first line
- /// sits at: a file's whole content at row 0, or a terminal's edit buffer
- /// at its anchor. Terminals materialize the buffer here on first use and
- /// grow it whenever an edit reaches past an end, seeding from — and from
- /// then on standing in for — the shell rows it swallows.
- /// `col` is the caller's cursor column: row `lo` is padded out to it with
- /// spaces, because a terminal cursor may sit past the end of a row (acme:
- /// you click anywhere and type there, which is what the insertion runs
- /// did by padding at render time). Insert-mode callers pass their column;
- /// everything else passes -1 and leaves the text alone.
- /// Null if the pane holds no editable text (an image or PDF projection).
+ /// Materialize or extend a terminal edit buffer with one exact allocation.
fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
if (pane.image != null or hasPdf(pane)) return null;
@@ -7477,29 +7624,49 @@ pub const Pardes = struct {
const fresh = pane.ovl == null;
const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
- const up = old.row - want_lo; // shell rows to prepend
- const down = want_hi - (old.row + lines - 1); // surface rows past its end
+ const up = old.row - want_lo;
+ const down = want_hi - (old.row + lines - 1);
if (fresh or up > 0 or down > 0) {
const rows = term_pane.shellRows(p, pane) catch return null;
- var buf: std.ArrayList(u8) = .empty;
- defer buf.deinit(p.gpa);
const row0 = old.row - @max(0, up);
+ var total: usize = 0;
var g: i32 = row0;
- while (g < old.row) : (g += 1) {
- buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null;
- buf.append(p.gpa, '\n') catch return null;
- }
- if (fresh)
- buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null
+ while (g < old.row) : (g += 1)
+ total += (if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)].len else 0) + 1;
+ total += if (fresh)
+ (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)].len else 0)
else
- buf.appendSlice(p.gpa, old.text) catch return null;
+ old.text.len;
var k: i32 = 0;
while (k < down) : (k += 1) {
const src = old.row + old.rows + k;
- buf.append(p.gpa, '\n') catch return null;
- buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null;
+ total += 1 + if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)].len else 0;
+ }
+ const text = p.gpa.alloc(u8, total) catch return null;
+ var at: usize = 0;
+ g = row0;
+ while (g < old.row) : (g += 1) {
+ const src = if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "";
+ @memcpy(text[at..][0..src.len], src);
+ at += src.len;
+ text[at] = '\n';
+ at += 1;
+ }
+ const middle = if (fresh)
+ (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "")
+ else
+ old.text;
+ @memcpy(text[at..][0..middle.len], middle);
+ at += middle.len;
+ k = 0;
+ while (k < down) : (k += 1) {
+ const src_row = old.row + old.rows + k;
+ const src = if (@as(usize, @intCast(src_row)) < rows.len) rows[@intCast(src_row)] else "";
+ text[at] = '\n';
+ at += 1;
+ @memcpy(text[at..][0..src.len], src);
+ at += src.len;
}
- const text = buf.toOwnedSlice(p.gpa) catch return null;
if (pane.ovl) |o| p.gpa.free(o.text);
pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text };
}
@@ -7596,39 +7763,35 @@ pub const Pardes = struct {
.open_below, .open_above => {
pane.vsel.active = false;
const below = where == .open_below;
- // helix `open`: the new line copies the current line's full
- // indent LEVELS; <n>o opens n lines, cursor on the first
const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row;
const eb = p.editText(pane, abs, abs, -1) orelse return;
const row: usize = @intCast(@max(0, abs - eb.row0));
const ind = modal.hxIndentString(modal.lineSlice(eb.text, row));
const arena = p.scratch.allocator();
- var block: std.ArrayList(u8) = .empty;
+ const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return;
+ const block = arena.alloc(u8, block_len) catch return;
+ var block_at: usize = 0;
for (0..cnt) |_| {
if (below) {
- block.append(arena, '\n') catch return;
- block.appendSlice(arena, ind) catch return;
- } else {
- block.appendSlice(arena, ind) catch return;
- block.append(arena, '\n') catch return;
+ block[block_at] = '\n';
+ block_at += 1;
+ }
+ @memcpy(block[block_at..][0..ind.len], ind);
+ block_at += ind.len;
+ if (!below) {
+ block[block_at] = '\n';
+ block_at += 1;
}
}
const at: modal.Cursor = if (below)
.{ .row = row, .col = modal.lineSlice(eb.text, row).len }
else
.{ .row = row, .col = 0 };
- const new = modal.insertAt(p.gpa, eb.text, at, block.items) catch return;
+ const new = modal.insertAt(p.gpa, eb.text, at, block) catch return;
p.setEditText(pane, new);
pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0;
pane.cur_col = @intCast(ind.len);
pane.cur_pinned = true;
- // helix `open` with a COUNT leaves one cursor per line it
- // opened — `3o` then typing fills all three. The lines are
- // consecutive and start at the cursor, so this is just cnt
- // points a row apart, the first one primary.
- // (Inside a replay pass the extra ranges cannot be created:
- // a pass produces exactly one range. `3o` at several cursors
- // therefore opens the lines but keeps the cursors it had.)
if (cnt > 1 and !p.multi_on) opened: {
const pl2 = p.paneCursorLines(pane) catch break :opened;
const t2 = p.flatSurface(pane, pl2) catch break :opened;
@@ -7904,7 +8067,6 @@ pub const Pardes = struct {
fn vselBounds(pane: *Pane) Bounds {
return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col });
}
-
/// the char selection as text. Read off the pane's SURFACE (file content /
/// terminal shell rows + edit buffer), not the rendered body: a yank of a
/// whole line has to carry its newline, the way a file's does, or p/P
@@ -7920,14 +8082,28 @@ pub const Pardes = struct {
fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 {
const arena = p.scratch.allocator();
const pl = p.paneCursorLines(pane) catch return "";
- var out: std.ArrayList(u8) = .empty;
+ const rows_count: usize = @intCast(@max(0, r1 - r0 + 1));
+ var total: usize = rows_count -| 1;
var i = r0;
while (i <= r1) : (i += 1) {
- if (i > r0) out.append(arena, '\n') catch return "";
if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len)
- out.appendSlice(arena, pl.lines[@intCast(i)]) catch return "";
+ total += pl.lines[@intCast(i)].len;
+ }
+ const out = arena.alloc(u8, total) catch return "";
+ var at: usize = 0;
+ i = r0;
+ while (i <= r1) : (i += 1) {
+ if (i > r0) {
+ out[at] = '\n';
+ at += 1;
+ }
+ if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) {
+ const line = pl.lines[@intCast(i)];
+ @memcpy(out[at..][0..line.len], line);
+ at += line.len;
+ }
}
- return out.items;
+ return out;
}
/// the whole pane surface as flat text, absolute rows from 0 (read-only:
@@ -8003,18 +8179,14 @@ pub const Pardes = struct {
}
fn normalYank(p: *Pardes, pane: *Pane) void {
- pane.select = false; // helix yank exits select mode, selection KEPT
+ pane.select = false;
if (pane.vsel.active) {
- // helix: yank moves neither the cursor nor the selection
p.setYank(p.vselText(pane));
return;
}
if (pane.msel.active) {
p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)));
} else {
- // helix: a bare cursor IS a 1-wide selection — yank the char
- // under it, file and terminal alike (the line-yank vim-ism died
- // with the motion model). vselText already speaks both.
const save = pane.vsel;
pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false };
p.setYank(p.vselText(pane));
@@ -8038,9 +8210,10 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
var y: []const u8 = y0;
if (cnt > 1) {
- var buf: std.ArrayList(u8) = .empty;
- for (0..cnt) |_| buf.appendSlice(arena, y0) catch return;
- y = buf.items;
+ const total = std.math.mul(usize, cnt, y0.len) catch return;
+ const buf = arena.alloc(u8, total) catch return;
+ for (0..cnt) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0);
+ y = buf;
}
const b: Bounds = if (pane.vsel.active)
vselBounds(pane)
@@ -8102,9 +8275,6 @@ pub const Pardes = struct {
if (pane.vsel.active) {
const b = vselBounds(pane);
const eb = p.editTextEol(pane, b) orelse return;
- // helix: a LINEWISE selection (whole lines incl. their final
- // newline) deletes the lines and opens a fresh one above,
- // copying the indent of the line that moves up
const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len;
if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) {
p.setYank(p.vselText(pane));
@@ -8149,17 +8319,23 @@ pub const Pardes = struct {
const r1 = @max(pane.msel.r0, pane.msel.r1);
p.pushUndo(pane);
const eb = p.editText(pane, r0, r1 + 1, -1) orelse return;
- // keep one empty line at r0, drop the rest; yank the removed
- var buf: std.ArrayList(u8) = .empty;
const arena = p.scratch.allocator();
const lo: usize = @intCast(@max(0, r0 - eb.row0));
const hi: usize = @intCast(@max(0, r1 - eb.row0));
- var r: usize = lo;
- while (r <= hi) : (r += 1) {
- if (r > lo) buf.append(arena, '\n') catch break;
- buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break;
+ var total: usize = hi - lo;
+ for (lo..hi + 1) |r| total += modal.lineSlice(eb.text, r).len;
+ const buf = arena.alloc(u8, total) catch return;
+ var at: usize = 0;
+ for (lo..hi + 1) |r| {
+ if (r > lo) {
+ buf[at] = '\n';
+ at += 1;
+ }
+ const line = modal.lineSlice(eb.text, r);
+ @memcpy(buf[at..][0..line.len], line);
+ at += line.len;
}
- p.setYank(buf.items);
+ p.setYank(buf);
var body: []u8 = eb.text;
if (hi > lo) {
const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch {
@@ -8310,16 +8486,22 @@ pub const Pardes = struct {
/// apply a change list. gpa-owned result.
fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(p.gpa);
+ var total = text.len;
+ for (chs) |ch| total = total - (ch.to - ch.from) + ch.ins.len;
+ const out = try p.gpa.alloc(u8, total);
+ errdefer p.gpa.free(out);
var copied: usize = 0;
+ var at: usize = 0;
for (chs) |ch| {
- try out.appendSlice(p.gpa, text[copied..ch.from]);
- try out.appendSlice(p.gpa, ch.ins);
+ const unchanged = text[copied..ch.from];
+ @memcpy(out[at..][0..unchanged.len], unchanged);
+ at += unchanged.len;
+ @memcpy(out[at..][0..ch.ins.len], ch.ins);
+ at += ch.ins.len;
copied = ch.to;
}
- try out.appendSlice(p.gpa, text[copied..]);
- return out.toOwnedSlice(p.gpa);
+ @memcpy(out[at..], text[copied..]);
+ return out;
}
/// `J`: helix join_selections — join the selection's line span (a bare
@@ -8340,25 +8522,27 @@ pub const Pardes = struct {
if (span.start == end) end = @min(end + 1, nlines - 1);
if (end <= span.start) return;
const arena = p.scratch.allocator();
- var chs: std.ArrayList(TextChange) = .empty;
+ const chs = arena.alloc(TextChange, end - span.start) catch return;
+ var chs_len: usize = 0;
var l = span.start;
while (l < end) : (l += 1) {
const from = modal.hxLineEndIdx(text, l);
var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " ";
- chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return;
+ chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
+ chs_len += 1;
}
- if (chs.items.len == 0) return;
+ if (chs_len == 0) return;
p.pushUndo(pane);
const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) });
const anc_off = if (pane.vsel.active)
modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) })
else
cur_off;
- const new = p.applyChanges(text, chs.items) catch return;
- const nc = mapThroughChanges(chs.items, cur_off);
- const na = mapThroughChanges(chs.items, anc_off);
+ const new = p.applyChanges(text, chs[0..chs_len]) catch return;
+ const nc = mapThroughChanges(chs[0..chs_len], cur_off);
+ const na = mapThroughChanges(chs[0..chs_len], anc_off);
p.setEditText(pane, new);
const cc = modal.hxPos(new, nc);
const ac = modal.hxPos(new, na);
@@ -8390,7 +8574,8 @@ pub const Pardes = struct {
// one run of spaces, sliced per line: `>` never inserts more than this
const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return;
@memset(pad, ' ');
- var chs: std.ArrayList(TextChange) = .empty;
+ const chs = arena.alloc(TextChange, span.end - span.start + 1) catch return;
+ var chs_len: usize = 0;
var l = span.start;
while (l <= span.end) : (l += 1) {
const ls = modal.lineStartOffset(text, l);
@@ -8400,7 +8585,8 @@ pub const Pardes = struct {
if (nw == line.len) continue; // blank lines stay blank (helix)
if (add) {
const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W);
- chs.append(arena, .{ .from = ls, .to = ls, .ins = pad[0..ins] }) catch return;
+ chs[chs_len] = .{ .from = ls, .to = ls, .ins = pad[0..ins] };
+ chs_len += 1;
} else {
const want = modal.INDENT_W * cnt;
var w: usize = 0;
@@ -8414,19 +8600,22 @@ pub const Pardes = struct {
pos += 1;
if (w >= want) break;
}
- if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = "" }) catch return;
+ if (pos > 0) {
+ chs[chs_len] = .{ .from = ls, .to = ls + pos, .ins = "" };
+ chs_len += 1;
+ }
}
}
- if (chs.items.len == 0) return;
+ if (chs_len == 0) return;
p.pushUndo(pane);
const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) });
const anc_off = if (pane.vsel.active)
modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) })
else
cur_off;
- const new = p.applyChanges(text, chs.items) catch return;
- const nc = mapThroughChanges(chs.items, cur_off);
- const na = mapThroughChanges(chs.items, anc_off);
+ const new = p.applyChanges(text, chs[0..chs_len]) catch return;
+ const nc = mapThroughChanges(chs[0..chs_len], cur_off);
+ const na = mapThroughChanges(chs[0..chs_len], anc_off);
p.setEditText(pane, new);
const cc = modal.hxPos(new, nc);
const ac = modal.hxPos(new, na);
@@ -8480,13 +8669,17 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
// the lines the ranges cover, each ONE ONCE and in order (helix's
// min_next_line: two cursors on one line comment it once)
- var lines: std.ArrayList(usize) = .empty;
+ const lines = arena.alloc(usize, nlines) catch return;
+ var lines_len: usize = 0;
var next: usize = 0;
for (rs[0..got.n]) |r| {
const span = rangeLineSpan(text, r);
var l = @max(span.start, next);
const end = @min(span.end + 1, nlines);
- while (l < end) : (l += 1) lines.append(arena, l) catch return;
+ while (l < end) : (l += 1) {
+ lines[lines_len] = l;
+ lines_len += 1;
+ }
next = @max(next, end);
}
// which token: the file's EXTENSION, which is the same thing
@@ -8505,7 +8698,7 @@ pub const Pardes = struct {
var indent: usize = std.math.maxInt(usize);
var margin: usize = 1;
var live: usize = 0;
- for (lines.items) |l| {
+ for (lines[0..lines_len]) |l| {
const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)];
const nw = modal.firstNonWs(line);
if (nw == line.len) continue;
@@ -8516,8 +8709,9 @@ pub const Pardes = struct {
}
if (live == 0) return; // nothing but blank lines
const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return;
- var chs: std.ArrayList(TextChange) = .empty;
- for (lines.items) |l| {
+ const chs = arena.alloc(TextChange, lines_len) catch return;
+ var chs_len: usize = 0;
+ for (lines[0..lines_len]) |l| {
const ls = modal.lineStartOffset(text, l);
const le = modal.hxLineEndIdx(text, l);
const line = text[ls..le];
@@ -8525,20 +8719,21 @@ pub const Pardes = struct {
const at = ls + indent;
// the @min can never bind: margin is 1 only when every line has a
// space after its own token, which is a byte past `at + token.len`
- chs.append(arena, if (commented)
+ chs[chs_len] = if (commented)
.{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" }
else
- .{ .from = at, .to = at, .ins = ins }) catch return;
+ .{ .from = at, .to = at, .ins = ins };
+ chs_len += 1;
}
p.pushUndo(pane);
// one edit, and the WHOLE selection rides through it (helix maps the
// selection with the transaction)
var cells: [MAX_SELS]SelRange = undefined;
- const new = p.applyChanges(text, chs.items) catch return;
+ const new = p.applyChanges(text, chs[0..chs_len]) catch return;
for (rs[0..got.n], 0..) |r, i| {
const c = rangeCells(new, .{
- .anchor = mapThroughChanges(chs.items, r.anchor),
- .head = mapThroughChanges(chs.items, r.head),
+ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor),
+ .head = mapThroughChanges(chs[0..chs_len], r.head),
});
const cc = modal.hxPos(new, c.cur);
const ac = modal.hxPos(new, c.anc);
@@ -8750,99 +8945,99 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
}
+ fn pushFileHistory(p: *Pardes, slots: *[UNDO_MAX]FileSnap, len: *usize, snap: FileSnap) void {
+ if (len.* == slots.len) {
+ p.gpa.free(slots[0].content);
+ std.mem.copyForwards(FileSnap, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = snap;
+ len.* += 1;
+ }
+
+ fn pushEditHistory(p: *Pardes, slots: *[EDIT_UNDO_MAX]EditSnap, len: *usize, snap: EditSnap) void {
+ if (len.* == slots.len) {
+ if (slots[0].ovl) |o| p.gpa.free(o.text);
+ std.mem.copyForwards(EditSnap, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = snap;
+ len.* += 1;
+ }
+
fn pushUndo(p: *Pardes, pane: *Pane) void {
// one keystroke, one undo step — even when it edited at ten cursors.
- // (The content-equality guard below cannot do this on its own: by the
- // second pass the buffer HAS changed, so it would push again.)
if (p.multi_on and !p.multi_first) return;
if (pane.file) |*f| {
- if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return;
+ if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return;
const snap: FileSnap = .{
.content = p.gpa.dupe(u8, f.content) catch return,
.cur_row = pane.cur_row,
.cur_col = pane.cur_col,
.vsel = pane.vsel,
};
- f.undo.append(p.gpa, snap) catch {
- p.gpa.free(snap.content);
- return;
- };
- if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content);
- for (f.redo.items) |s| p.gpa.free(s.content);
- f.redo.clearRetainingCapacity();
+ p.pushFileHistory(&f.undo, &f.undo_len, snap);
+ for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
+ f.redo_len = 0;
} else {
const cur = pane.ovl orelse Ovl{ .rows = 0 };
- if (pane.ed_undo.getLastOrNull()) |top| {
+ if (pane.ed_undo_len > 0) {
+ const top = pane.ed_undo[pane.ed_undo_len - 1];
const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and
o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null;
if (same) return;
}
const snap = term_pane.snap(p, pane) orelse return;
- pane.ed_undo.append(p.gpa, snap) catch {
- if (snap.ovl) |o| p.gpa.free(o.text);
- return;
- };
- if (pane.ed_undo.items.len > EDIT_UNDO_MAX) {
- const drop = pane.ed_undo.orderedRemove(0);
- if (drop.ovl) |o| p.gpa.free(o.text);
- }
- for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.ed_redo.clearRetainingCapacity();
+ p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, snap);
+ for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
+ pane.ed_redo_len = 0;
}
}
fn doUndo(p: *Pardes, pane: *Pane) void {
if (pane.file) |*f| {
- if (f.undo.items.len == 0) return;
+ if (f.undo_len == 0) return;
const cur: FileSnap = .{
.content = p.gpa.dupe(u8, f.content) catch return,
.cur_row = pane.cur_row,
.cur_col = pane.cur_col,
.vsel = pane.vsel,
};
- f.redo.append(p.gpa, cur) catch {
- p.gpa.free(cur.content);
- return;
- };
- const prev = f.undo.pop() orelse return;
+ p.pushFileHistory(&f.redo, &f.redo_len, cur);
+ f.undo_len -= 1;
+ const prev = f.undo[f.undo_len];
file_pane.setContent(p, f, prev.content);
file_pane.restoreSnap(pane, f, prev);
return;
}
- if (pane.ed_undo.items.len == 0) return;
+ if (pane.ed_undo_len == 0) return;
const cur = term_pane.snap(p, pane) orelse return;
- pane.ed_redo.append(p.gpa, cur) catch {
- if (cur.ovl) |o| p.gpa.free(o.text);
- return;
- };
- term_pane.restoreSnap(p, pane, pane.ed_undo.pop() orelse return);
+ p.pushEditHistory(&pane.ed_redo, &pane.ed_redo_len, cur);
+ pane.ed_undo_len -= 1;
+ term_pane.restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
}
fn doRedo(p: *Pardes, pane: *Pane) void {
if (pane.file) |*f| {
- if (f.redo.items.len == 0) return;
+ if (f.redo_len == 0) return;
const cur: FileSnap = .{
.content = p.gpa.dupe(u8, f.content) catch return,
.cur_row = pane.cur_row,
.cur_col = pane.cur_col,
.vsel = pane.vsel,
};
- f.undo.append(p.gpa, cur) catch {
- p.gpa.free(cur.content);
- return;
- };
- const next = f.redo.pop() orelse return;
+ p.pushFileHistory(&f.undo, &f.undo_len, cur);
+ f.redo_len -= 1;
+ const next = f.redo[f.redo_len];
file_pane.setContent(p, f, next.content);
file_pane.restoreSnap(pane, f, next);
return;
}
- if (pane.ed_redo.items.len == 0) return;
+ if (pane.ed_redo_len == 0) return;
const cur = term_pane.snap(p, pane) orelse return;
- pane.ed_undo.append(p.gpa, cur) catch {
- if (cur.ovl) |o| p.gpa.free(o.text);
- return;
- };
- term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return);
+ p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, cur);
+ pane.ed_redo_len -= 1;
+ term_pane.restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
}
/// Something outside pardes rewrote a file we have open. Commit the buffer
@@ -9274,7 +9469,7 @@ pub const Pardes = struct {
// row is ignored (a tag is one line) and the anchor stays where
// the press put it, so this is the mouse's `v`
const pane = p.panes[d.id] orelse return;
- const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
+ const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER);
pane.tag_col = @intCast(std.math.clamp(c, 0, end));
pane.tag_sel = pane.tag_col != pane.tag_anchor;
@@ -9854,6 +10049,7 @@ pub const Pardes = struct {
errdefer p.gpa.free(path);
const pane = try p.newDocPane(id);
pane.file = .{ .path = path, .content = content };
+ pane.kind = .file;
pane.cur_pinned = true;
return pane;
}
@@ -9871,6 +10067,7 @@ pub const Pardes = struct {
p.panes[0] = null;
const pane = try p.newDocPane(0);
pane.file = .{ .path = path, .content = copy };
+ pane.kind = .file;
pane.cur_pinned = true;
p.active = 0;
p.sync();
@@ -9878,14 +10075,15 @@ pub const Pardes = struct {
}
fn openImageView(p: *Pardes, id: usize, path: []const u8) !*Pane {
- const path_copy = try p.gpa.dupe(u8, path);
- errdefer p.gpa.free(path_copy);
+ const path_copy = try p.image_gpa.dupe(u8, path);
+ errdefer p.image_gpa.free(path_copy);
const pane = try p.newDocPane(id);
// Native pixels are the preference, not a capability observed at open
// time. drawImage dynamically falls back while a shell has no native
// image support; this matters because kitty detection finishes after
// argv images and restored panes have already been constructed.
pane.image = .{ .path = path_copy };
+ pane.kind = .image;
return pane;
}
@@ -9893,16 +10091,16 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return error.PdfDisabled;
var document = try pdf_impl.Document.open(path);
errdefer document.deinit();
- const page_sizes = try p.gpa.alloc(pdf_impl.PageSize, document.pages);
- errdefer p.gpa.free(page_sizes);
+ const page_sizes = try p.pdf_gpa.alloc(pdf_impl.PageSize, document.pages);
+ errdefer p.pdf_gpa.free(page_sizes);
for (page_sizes, 0..) |*size, page_number|
size.* = try document.pageSize(page_number);
- const page_starts = try p.gpa.alloc(u64, document.pages);
- errdefer p.gpa.free(page_starts);
- const page_heights = try p.gpa.alloc(u32, document.pages);
- errdefer p.gpa.free(page_heights);
- const path_copy = try p.gpa.dupe(u8, path);
- errdefer p.gpa.free(path_copy);
+ const page_starts = try p.pdf_gpa.alloc(u64, document.pages);
+ errdefer p.pdf_gpa.free(page_starts);
+ const page_heights = try p.pdf_gpa.alloc(u32, document.pages);
+ errdefer p.pdf_gpa.free(page_heights);
+ const path_copy = try p.pdf_gpa.dupe(u8, path);
+ errdefer p.pdf_gpa.free(path_copy);
const pane = try p.newDocPane(id);
const page = if (page_one_based > 0)
@min(page_one_based - 1, document.pages - 1)
@@ -9917,6 +10115,7 @@ pub const Pardes = struct {
.page_starts = page_starts,
.page_heights = page_heights,
};
+ pane.kind = .pdf;
pane.cur_pinned = true;
return pane;
}
@@ -9929,7 +10128,7 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return;
_ = p;
pv.render_tried = false;
- for (pv.rasters.items) |*raster| if (raster.valid and raster.page == pv.page) {
+ for (pv.rasters[0..pv.rasters_len]) |*raster| if (raster.valid and raster.page == pv.page) {
raster.tried = false;
break;
};
@@ -9942,21 +10141,21 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return;
_ = p;
pv.render_tried = false;
- for (pv.rasters.items) |*raster| {
+ for (pv.rasters[0..pv.rasters_len]) |*raster| {
if (raster.valid) raster.tried = false;
}
}
fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void {
if (comptime !pdf_enabled) return;
- if (raster.rgba.len > 0) p.gpa.free(raster.rgba);
+ if (raster.rgba.len > 0) p.pdf_gpa.free(raster.rgba);
raster.* = .{};
}
fn dropPdfRaster(p: *Pardes, pv: *PdfView) void {
if (comptime !pdf_enabled) return;
- for (pv.rasters.items) |*raster| p.releasePdfRaster(raster);
- pv.rasters.clearRetainingCapacity();
+ for (pv.rasters[0..pv.rasters_len]) |*raster| p.releasePdfRaster(raster);
+ pv.rasters_len = 0;
pv.rgba = &.{};
pv.iw = 0;
pv.ih = 0;
@@ -9966,15 +10165,15 @@ pub const Pardes = struct {
fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void {
if (comptime !pdf_enabled) return;
- if (pv.search_results) |*results| results.deinit(p.gpa);
+ if (pv.search_results) |*results| results.deinit(p.pdf_gpa);
pv.search_results = null;
}
fn dropPdfSelection(p: *Pardes, pv: *PdfView) void {
if (comptime !pdf_enabled) return;
- if (pv.selection) |*selection| selection.deinit(p.gpa);
+ if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
pv.selection = null;
- if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text);
+ if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text);
pv.selection_text = &.{};
pv.selection_anchor = null;
pv.selection_head = null;
@@ -10001,7 +10200,7 @@ pub const Pardes = struct {
p.dropPdfSearchResults(pv);
p.dropPdfSelection(pv);
clearPdfDrag(pv);
- if (pv.text.len > 0) p.gpa.free(pv.text);
+ if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
pv.text = &.{};
pv.text_tried = false;
pv.text_scroll = 0;
@@ -10012,11 +10211,11 @@ pub const Pardes = struct {
/// Replace the full PDF query and discard only query-derived page state.
/// Resolution is lazy in drawPdf; fit and pan remain placement-only state.
- fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void {
+ fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) !void {
if (comptime !pdf_enabled) return;
if (std.mem.eql(u8, pv.search_query, query)) return;
- const owned = p.gpa.dupe(u8, query) catch return;
- if (pv.search_query.len > 0) p.gpa.free(pv.search_query);
+ const owned = try p.pdf_gpa.dupe(u8, query);
+ if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query);
pv.search_query = owned;
pv.search_hit = 0;
p.dropPdfSearchResults(pv);
@@ -10031,7 +10230,7 @@ pub const Pardes = struct {
pv.highlights_dirty = false;
p.dropPdfSearchResults(pv);
if (pv.search_query.len == 0) return;
- const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return;
+ const results = pv.document.search(p.pdf_gpa, pv.page, pv.search_query) catch return;
pv.search_hit = if (results.hit_count == 0)
0
else
@@ -10170,7 +10369,7 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return null;
if (!pv.outline_tried) {
pv.outline_tried = true;
- pv.outline = pv.document.outline(p.gpa) catch null;
+ pv.outline = pv.document.outline(p.pdf_gpa) catch null;
}
return if (pv.outline) |*outline| outline else null;
}
@@ -10472,7 +10671,7 @@ pub const Pardes = struct {
fn pdfRasterForPage(pv: *PdfView, page: usize) ?*PdfRaster {
if (comptime !pdf_enabled) return null;
- for (pv.rasters.items) |*raster|
+ for (pv.rasters[0..pv.rasters_len]) |*raster|
if (raster.valid and raster.page == page) return raster;
return null;
}
@@ -10650,14 +10849,14 @@ pub const Pardes = struct {
pv.selection_anchor != null and pv.selection_head != null and
pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and
pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged;
- var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed;
+ var selection = pv.document.select(p.pdf_gpa, pv.page, start, end) catch return .failed;
const text = pv.document.copySelection(
- p.gpa,
+ p.pdf_gpa,
pv.page,
selection.start,
selection.end,
) catch {
- selection.deinit(p.gpa);
+ selection.deinit(p.pdf_gpa);
return .failed;
};
@@ -10729,31 +10928,25 @@ pub const Pardes = struct {
fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight {
if (comptime !pdf_enabled) return &.{};
- var highlights: std.ArrayList(PdfHighlight) = .empty;
const arena = p.scratch.allocator();
+ const search_len = if (pv.search_results) |results| results.quads.len else 0;
+ const selection_len = if (pv.selection) |selection| selection.quads.len else 0;
+ const highlights = try arena.alloc(PdfHighlight, search_len + selection_len);
+ var n: usize = 0;
if (pv.search_results) |results| {
- try highlights.ensureUnusedCapacity(arena, results.quads.len);
- for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init(
- item.quad,
- .{ 0xff, 0xd5, 0x4f, 0x70 },
- .search,
- ));
+ for (results.quads) |item| {
+ highlights[n] = pdf_impl.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search);
+ n += 1;
+ }
}
if (pv.selection) |selection| {
- try highlights.ensureUnusedCapacity(arena, selection.quads.len);
- // a page is not a grid of cells, so this is the one selection that
- // paints as a translucent wash over rendered pixels rather than as
- // a cell's background — but it is the same selection, so it is the
- // same colour. The alpha stays hardcoded: it is what makes the
- // glyphs underneath readable, and no theme has an opinion on it.
const sel = p.theme().sel_bg;
- for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init(
- quad,
- .{ sel[0], sel[1], sel[2], 0x78 },
- .selection,
- ));
+ for (selection.quads) |quad| {
+ highlights[n] = pdf_impl.Highlight.init(quad, .{ sel[0], sel[1], sel[2], 0x78 }, .selection);
+ n += 1;
+ }
}
- return highlights.items;
+ return highlights;
}
/// Move one normalized pan axis by a distance measured in DISPLAY pixels.
@@ -11227,7 +11420,10 @@ pub const Pardes = struct {
// where the click put the cursor. Asking again is free: the
// same pattern refills its own list rather than opening a
// second, so clicking a word repeatedly walks its hits.
- p.runSearch(id, trimmed, .text, .cursor);
+ p.runSearch(id, trimmed, .text, .cursor) catch |err| {
+ p.reportError(id, "search", err);
+ return;
+ };
const at = pane.search_row;
_ = p.searchStep(id, 1);
// past the last hit, back to the first: acme's search is a
@@ -11387,17 +11583,7 @@ pub const Pardes = struct {
/// ran as a command nobody typed. A selection is only an argument to
/// something that has somewhere to put it.
fn takesArg(b: Builtin) bool {
- switch (b) {
- .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Shell, .Look, .Exec => return true,
- // Font is a BRANCH and not another name in that prong for the
- // reason output_pane.traits has the same shape: it is not in the
- // enum at all off the gui, and a comptime-false `if` is the one
- // form that is not analysed there.
- else => {
- if (platform == .gui) if (b == .Font) return true;
- return false;
- },
- }
+ return builtins.registry.takesArg(b);
}
fn applyStartupConfig(p: *Pardes) void {
@@ -11420,7 +11606,7 @@ pub const Pardes = struct {
if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor
const pane = p.panes[id] orelse return;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg };
- inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c);
+ inline for (comptime builtins.registry.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c);
}
/// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a
@@ -11509,10 +11695,11 @@ pub const Pardes = struct {
pub fn dumpState(p: *Pardes) !void {
const arena = p.scratch.allocator();
var slot_to_pane: [MAX_PANES]?usize = @splat(null);
- var panes: std.ArrayList(dump.Pane) = .empty;
+ var panes: [MAX_PANES]dump.Pane = undefined;
+ var panes_len: usize = 0;
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
- slot_to_pane[id] = panes.items.len;
+ slot_to_pane[id] = panes_len;
const tag = try p.tagText(arena, pane);
const body = try p.bodyText(arena, pane);
const scroll: usize = @intCast(@max(0, pane.scroll()));
@@ -11536,7 +11723,7 @@ pub const Pardes = struct {
},
} else if (hasPdf(pane)) blk: {
if (comptime !pdf_enabled) unreachable;
- const pv = pane.pdf.?;
+ const pv = &pane.pdf.?;
const bytes = look.readFile(arena, pv.path) catch "";
// Keep the dump schema backwards-compatible: a raster-backed
// document rides the existing binary image record, while its
@@ -11574,7 +11761,9 @@ pub const Pardes = struct {
// the stream text: whole history, prompts hidden outside tty
// mode, the edit buffer standing in for the rows it covers
const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
- var stream_text: std.ArrayList(u8) = .empty;
+ const extra = if (pane.ovl) |o| o.text.len else 0;
+ const stream_buf = try arena.alloc(u8, full.len + extra);
+ var stream_len: usize = 0;
var lines = std.mem.splitAny(u8, full, "\n");
var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
var row: i32 = 0;
@@ -11588,13 +11777,19 @@ pub const Pardes = struct {
skip -= 1;
continue;
}
- if (row > 0) try stream_text.append(arena, '\n');
+ if (row > 0) {
+ stream_buf[stream_len] = '\n';
+ stream_len += 1;
+ }
if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) {
- try stream_text.appendSlice(arena, o.text);
+ @memcpy(stream_buf[stream_len..][0..o.text.len], o.text);
+ stream_len += o.text.len;
skip = o.rows - 1;
continue;
};
- try stream_text.appendSlice(arena, if (is_prompt) "" else raw);
+ const shown = if (is_prompt) "" else raw;
+ @memcpy(stream_buf[stream_len..][0..shown.len], shown);
+ stream_len += shown.len;
}
break :blk .{
.kind = .terminal,
@@ -11606,8 +11801,8 @@ pub const Pardes = struct {
.vweight = pane.vweight,
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = stream_text.items,
- .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items),
+ .stream = stream_buf[0..stream_len],
+ .stream_b64 = try dump.encodeBytes(arena, try pane.ttyReplayBytes(arena)),
.cursor = .{
.col = pane.vt.screens.active.cursor.x,
.row = pane.vt.screens.active.cursor.y,
@@ -11615,17 +11810,23 @@ pub const Pardes = struct {
},
};
};
- try panes.append(arena, dp);
+ panes[panes_len] = dp;
+ panes_len += 1;
}
- var columns: std.ArrayList(dump.Column) = .empty;
+ var columns: [MAX_COLS]dump.Column = undefined;
+ var columns_len: usize = 0;
+ var column_ids: [MAX_COLS][MAX_PANES]usize = undefined;
for (0..p.ncol) |c| {
- var ids: std.ArrayList(usize) = .empty;
- for (0..p.col_n[c]) |k| {
- if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact);
+ var ids_len: usize = 0;
+ for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_terms[c][k]]) |compact| {
+ column_ids[c][ids_len] = compact;
+ ids_len += 1;
+ };
+ if (ids_len > 0) {
+ columns[columns_len] = .{ .weight = p.col_weight[c], .panes = column_ids[c][0..ids_len] };
+ columns_len += 1;
}
- if (ids.items.len > 0)
- try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items });
}
const state: dump.State = .{
@@ -11633,8 +11834,8 @@ pub const Pardes = struct {
.active = slot_to_pane[p.active] orelse 0,
.topbar = config.topbar_str,
.theme = p.theme().name,
- .columns = columns.items,
- .panes = panes.items,
+ .columns = columns[0..columns_len],
+ .panes = panes[0..panes_len],
};
try dump.validate(state);
var out: std.Io.Writer.Allocating = .init(p.gpa);
@@ -11649,8 +11850,20 @@ pub const Pardes = struct {
/// by replaying their raw VT streams into fresh emulators), no spawns —
/// loaded terminals are dead replays, scrollable and selectable.
pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
+ const image_gpa = opts.image_allocator orelse gpa;
+ const pdf_gpa = opts.pdf_allocator orelse gpa;
+ const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa;
const p = try gpa.create(Pardes);
- p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ p.* = .{
+ .gpa = gpa,
+ .image_gpa = image_gpa,
+ .pdf_gpa = pdf_gpa,
+ .tree_sitter_gpa = tree_sitter_gpa,
+ .opts = opts,
+ .screen_w = opts.cols,
+ .screen_h = opts.rows,
+ .scratch = .init(gpa),
+ };
errdefer p.deinit();
const st = try dump.readZon(gpa, zon_bytes, "load");
defer dump.free(gpa, st);
@@ -11681,7 +11894,7 @@ pub const Pardes = struct {
p.panes[i] = pane;
if (t.stream_b64.len > 0) {
const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64);
- pane.tty_stream.appendSlice(gpa, bytes) catch {};
+ pane.appendTtyReplay(bytes);
pane.stream.nextSlice(bytes);
pane.vt.screens.active.scroll(.active);
if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
@@ -11708,6 +11921,7 @@ pub const Pardes = struct {
break :blk ov;
} else null;
pane.file = .{ .path = path, .content = content, .output = out, .scroll = src.scroll };
+ pane.kind = .file;
pane.cur_pinned = true;
pane.cur_row = @intCast(src.scroll);
pane.cols = @max(1, src.cols);
@@ -11723,13 +11937,16 @@ pub const Pardes = struct {
},
.image => restore_image: {
const im = src.image.?;
- const raw: []u8 = if (im.bytes_b64.len > 0)
- dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
+ const raw_gpa = if (pdf_enabled and look.isPdfPath(im.path)) gpa else p.image_gpa;
+ var raw: []u8 = if (im.bytes_b64.len > 0)
+ dump.decodeBytes(raw_gpa, im.bytes_b64) catch &.{}
else
&.{};
+ errdefer if (raw.len > 0) raw_gpa.free(raw);
if (comptime pdf_enabled) if (look.isPdfPath(im.path)) {
if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| {
if (raw.len > 0) gpa.free(raw);
+ raw = &.{};
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
break :restore_image;
@@ -11739,19 +11956,23 @@ pub const Pardes = struct {
const path = try gpa.dupe(u8, im.path);
errdefer gpa.free(path);
const content = if (raw.len > 0) raw else try gpa.dupe(u8, "");
+ raw = &.{};
errdefer if (content.len > 0) gpa.free(content);
const pane = try p.newDocPane(i);
pane.file = .{ .path = path, .content = content };
+ pane.kind = .file;
pane.cur_pinned = true;
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
break :restore_image;
};
- const path = try gpa.dupe(u8, im.path);
- errdefer gpa.free(path);
+ const path = try p.image_gpa.dupe(u8, im.path);
+ errdefer p.image_gpa.free(path);
const pane = try p.newDocPane(i);
pane.image = .{ .path = path, .raw = raw };
+ raw = &.{};
+ pane.kind = .image;
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
},
@@ -11792,7 +12013,7 @@ pub const Pardes = struct {
const said = std.mem.trimStart(u8, rest, " ");
if (std.mem.eql(u8, said, std.mem.trimStart(u8, curTail(pane), " ")) or
std.mem.eql(u8, said, std.mem.trimStart(u8, legacy, " "))) return;
- pane.tag_tail.appendSlice(p.gpa, rest) catch return;
+ if (!pane.appendTag(rest)) return;
pane.tag_init = true;
}
@@ -11971,7 +12192,7 @@ pub const Pardes = struct {
// produced output — bash sourced the rc and readline owns echo, so
// the greeting echoes on an OSC 133-marked row and hides cleanly;
// earlier, the kernel would echo it onto an unmarked row).
- if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream.items.len > 0) {
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream_len > 0) {
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
pane.greet = false;
@@ -12121,9 +12342,9 @@ pub const Pardes = struct {
// the same one input model renderPane cut off the tagline; only one
// of the two can ever be armed (a body key arms one, exitTagEdit
// clears both)
- const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null;
+ const prompt_at = pane.promptAt();
const text = if (prompt_at) |at|
- pane.tag_tail.items[@min(at, pane.tag_tail.items.len)..]
+ pane.tagSlice()[@min(at, pane.tag_tail_len)..]
else
pane.msg[0..pane.msg_len];
// The pending SPC path is the third thing that wants this row, and
@@ -12389,13 +12610,11 @@ pub const Pardes = struct {
// readable while you type instead of being pushed off the right edge by
// a long pattern.
//
- // search_at/pipe_at are TAIL offsets and tag_col is a rendered-tag
- // column, so the cut is the offset plus the prefix the tail is missing
- // — which is what tag.len - tail.len is, without formatting the prefix
- // a second time.
- const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null;
+ // The prompt offset is in the tail while tag_col is in the rendered
+ // tag, so add the live prefix length when clipping the editable span.
+ const prompt_at = pane.promptAt();
const tag_cut = if (prompt_at) |at|
- @min(tag.len, tag.len - @min(tag.len, pane.tag_tail.items.len) + at)
+ @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at)
else
tag.len;
_ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style);
@@ -12758,13 +12977,14 @@ pub const Pardes = struct {
// Remove first: owned RGBA never accumulates with document length,
// and backends see stale keys disappear in this same frame.
var index: usize = 0;
- while (index < pv.rasters.items.len) {
- if (pdfVisibleContains(visible, pv.rasters.items[index].page)) {
+ while (index < pv.rasters_len) {
+ if (pdfVisibleContains(visible, pv.rasters[index].page)) {
index += 1;
continue;
}
- p.releasePdfRaster(&pv.rasters.items[index]);
- _ = pv.rasters.swapRemove(index);
+ p.releasePdfRaster(&pv.rasters[index]);
+ pv.rasters_len -= 1;
+ if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len];
}
var page = visible.first;
@@ -12772,8 +12992,10 @@ pub const Pardes = struct {
while (page < end) : (page += 1) {
var raster = pdfRasterForPage(pv, page);
if (raster == null) {
- pv.rasters.append(p.gpa, .{ .valid = true, .page = page }) catch continue;
- raster = &pv.rasters.items[pv.rasters.items.len - 1];
+ if (pv.rasters_len == pv.rasters.len) continue;
+ pv.rasters[pv.rasters_len] = .{ .valid = true, .page = page };
+ pv.rasters_len += 1;
+ raster = &pv.rasters[pv.rasters_len - 1];
}
const slot = raster.?;
const decorated = page == pv.page and highlights.len > 0;
@@ -12786,17 +13008,17 @@ pub const Pardes = struct {
slot.request_valid = true;
const rendered = render: {
const fresh = if (decorated)
- pv.document.renderWithHighlightsAt(p.gpa, page, request, highlights) catch break :render null
+ pv.document.renderWithHighlightsAt(p.pdf_gpa, page, request, highlights) catch break :render null
else
- pv.document.renderAt(p.gpa, page, request) catch break :render null;
+ pv.document.renderAt(p.pdf_gpa, page, request) catch break :render null;
break :render fresh;
};
if (rendered) |fresh| {
pdf_impl.tintRgba(fresh.rgba, tint_key.mode, tint_key.colors) catch {
- p.gpa.free(fresh.rgba);
+ p.pdf_gpa.free(fresh.rgba);
continue;
};
- if (slot.rgba.len > 0) p.gpa.free(slot.rgba);
+ if (slot.rgba.len > 0) p.pdf_gpa.free(slot.rgba);
slot.rgba = fresh.rgba;
slot.iw = fresh.width;
slot.ih = fresh.height;
@@ -12877,7 +13099,7 @@ pub const Pardes = struct {
if (!iv.tried) {
iv.tried = true;
const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch "");
- if (image.decode(p.gpa, bytes)) |d| {
+ if (image.decode(p.image_gpa, bytes)) |d| {
iv.rgba = d.rgba;
iv.iw = d.w;
iv.ih = d.h;
@@ -12908,12 +13130,12 @@ pub const Pardes = struct {
if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or
iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii)
{
- if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ if (iv.grid.len > 0) p.image_gpa.free(iv.grid);
const pal = switch (iv.pmode) {
.commodore => image.petscii.commodore,
.terminal => image.ansiPalette(),
};
- const g = image.petscii.render(p.gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
+ const g = image.petscii.render(p.image_gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
iv.grid = g.cells;
iv.gw = g.gw;
iv.gh = g.gh;
@@ -12961,18 +13183,34 @@ pub const Pardes = struct {
if (hasPdf(pane)) {
const text = try p.ensurePdfText(pane);
const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0;
- var out: std.ArrayList(u8) = .empty;
+ const max_rows = pane.rows -| BOX_H;
+ var total: usize = 0;
+ var visible: usize = 0;
+ var count_lines = std.mem.splitScalar(u8, text, '\n');
+ var count_row: usize = 0;
+ while (count_lines.next()) |line| : (count_row += 1) {
+ if (count_row < scroll) continue;
+ if (visible >= max_rows) break;
+ total += line.len + @intFromBool(visible > 0);
+ visible += 1;
+ }
+ const out = try arena.alloc(u8, total);
+ var at: usize = 0;
var lines = std.mem.splitScalar(u8, text, '\n');
var row: usize = 0;
var wrote: usize = 0;
while (lines.next()) |line| : (row += 1) {
if (row < scroll) continue;
- if (wrote >= pane.rows -| BOX_H) break;
- if (wrote > 0) try out.append(arena, '\n');
- try out.appendSlice(arena, line);
+ if (wrote >= max_rows) break;
+ if (wrote > 0) {
+ out[at] = '\n';
+ at += 1;
+ }
+ @memcpy(out[at..][0..line.len], line);
+ at += line.len;
wrote += 1;
}
- return out.items;
+ return out;
}
if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.wrap_on);
return term_pane.bodyText(arena, pane);
diff --git a/src/pdf.zig b/src/pdf.zig
index 84d6a4e5..2a674172 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -7,6 +7,125 @@ const c = @cImport({
@cInclude("pdf_bridge.h");
});
+const allocation_header_size = 16;
+var bridge_allocator: std.mem.Allocator = undefined;
+var bridge_started = false;
+
+comptime {
+ if (allocation_header_size < @sizeOf(usize))
+ @compileError("PDF allocation header is too small");
+}
+
+fn bridgeAlloc(user: ?*anyopaque, size_arg: usize) callconv(.c) ?*anyopaque {
+ const user_ptr = user orelse return null;
+ const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr));
+ const size = @max(size_arg, 1);
+ const total = std.math.add(usize, allocation_header_size, size) catch
+ return null;
+ const bytes = allocator.alignedAlloc(u8, .@"16", total) catch return null;
+ const header: *align(16) usize = @ptrCast(bytes.ptr);
+ header.* = total;
+ return @ptrCast(bytes.ptr + allocation_header_size);
+}
+
+fn bridgeFree(user: ?*anyopaque, pointer: ?*anyopaque) callconv(.c) void {
+ const data = pointer orelse return;
+ const user_ptr = user orelse @panic("PDF allocator callback has no allocator");
+ const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr));
+ const data_bytes: [*]u8 = @ptrCast(data);
+ const base: [*]align(16) u8 =
+ @ptrCast(@alignCast(data_bytes - allocation_header_size));
+ const header: *align(16) usize = @ptrCast(base);
+ allocator.free(base[0..header.*]);
+}
+
+fn bridgeRealloc(
+ user: ?*anyopaque,
+ pointer: ?*anyopaque,
+ size_arg: usize,
+) callconv(.c) ?*anyopaque {
+ if (pointer == null) return bridgeAlloc(user, size_arg);
+ if (size_arg == 0) {
+ bridgeFree(user, pointer);
+ return null;
+ }
+
+ const user_ptr = user orelse return null;
+ const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr));
+ const total = std.math.add(usize, allocation_header_size, size_arg) catch
+ return null;
+ const data_bytes: [*]u8 = @ptrCast(pointer.?);
+ const base: [*]align(16) u8 =
+ @ptrCast(@alignCast(data_bytes - allocation_header_size));
+ const header: *align(16) usize = @ptrCast(base);
+ const resized = allocator.realloc(base[0..header.*], total) catch return null;
+ const resized_header: *align(16) usize = @ptrCast(resized.ptr);
+ resized_header.* = total;
+ return @ptrCast(resized.ptr + allocation_header_size);
+}
+
+/// Installs the allocator used by all subsequently opened PDF documents and
+/// by MuPDF itself. Call once before opening any documents.
+pub fn start(allocator: std.mem.Allocator) void {
+ if (bridge_started) @panic("PDF allocator is already started");
+ bridge_allocator = allocator;
+ const descriptor = c.pardes_pdf_allocator{
+ .user = &bridge_allocator,
+ .alloc = bridgeAlloc,
+ .realloc = bridgeRealloc,
+ .free = bridgeFree,
+ };
+ if (c.pardes_pdf_set_allocator(&descriptor) != c.PARDES_PDF_OK)
+ @panic("cannot start PDF allocator while documents are live");
+ bridge_started = true;
+}
+
+/// Restores libc and MuPDF's default allocator. Every Document must have been
+/// destroyed first so no allocation can outlive its allocator domain.
+pub fn stop() void {
+ if (!bridge_started) @panic("PDF allocator is not started");
+ if (c.pardes_pdf_set_allocator(null) != c.PARDES_PDF_OK)
+ @panic("cannot stop PDF allocator while documents are live");
+ bridge_started = false;
+ bridge_allocator = undefined;
+}
+
+test "PDF C allocator callbacks preserve and free exact allocations" {
+ var allocator = std.testing.allocator;
+
+ var live: ?*anyopaque = bridgeAlloc(&allocator, 4) orelse
+ return error.OutOfMemory;
+ defer if (live) |pointer| bridgeFree(&allocator, pointer);
+ const original: [*]u8 = @ptrCast(live.?);
+ @memcpy(original[0..4], "data");
+
+ live = bridgeRealloc(&allocator, live, 32) orelse
+ return error.OutOfMemory;
+ const grown: [*]u8 = @ptrCast(live.?);
+ try std.testing.expectEqualSlices(u8, "data", grown[0..4]);
+
+ try std.testing.expect(bridgeAlloc(
+ &allocator,
+ std.math.maxInt(usize),
+ ) == null);
+
+ const zero = bridgeAlloc(&allocator, 0) orelse return error.OutOfMemory;
+ bridgeFree(&allocator, zero);
+ try std.testing.expect(bridgeRealloc(&allocator, live, 0) == null);
+ live = null;
+}
+
+test "custom allocator owns a complete PDF document lifecycle" {
+ start(std.testing.allocator);
+ defer stop();
+
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+ const raster = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(raster.rgba);
+ try std.testing.expect(raster.rgba.len > 0);
+}
+
/// One raster-quality request. `minimum_*` asks MuPDF for enough source pixels
/// to avoid backend upscaling; `max_dimension` remains the hard allocation
/// ceiling for hostile page sizes and very large displays.
@@ -741,15 +860,22 @@ pub const Document = struct {
document: *Document,
gpa: std.mem.Allocator,
page: usize,
- start: Point,
- end: Point,
+ selection_start: Point,
+ selection_end: Point,
) !Selection {
const page_number = try document.checkedPage(page);
- if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+ if (!validPoint(selection_start) or !validPoint(selection_end))
+ return error.InvalidPoint;
var selected: c.pardes_pdf_selection =
std.mem.zeroes(c.pardes_pdf_selection);
- const status = c.pardes_pdf_select(document.handle, page_number, start, end, &selected);
+ const status = c.pardes_pdf_select(
+ document.handle,
+ page_number,
+ selection_start,
+ selection_end,
+ &selected,
+ );
if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
return error.TooManyResults;
if (status != c.PARDES_PDF_OK)
@@ -783,19 +909,20 @@ pub const Document = struct {
document: *Document,
gpa: std.mem.Allocator,
page: usize,
- start: Point,
- end: Point,
+ selection_start: Point,
+ selection_end: Point,
) ![]u8 {
const page_number = try document.checkedPage(page);
- if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+ if (!validPoint(selection_start) or !validPoint(selection_end))
+ return error.InvalidPoint;
var text: c.pardes_pdf_owned_text =
std.mem.zeroes(c.pardes_pdf_owned_text);
if (c.pardes_pdf_copy_selection(
document.handle,
page_number,
- start,
- end,
+ selection_start,
+ selection_end,
&text,
) != c.PARDES_PDF_OK) return error.TextFailed;
defer c.pardes_pdf_drop_owned_text(document.handle, text.handle);
@@ -857,93 +984,120 @@ fn expectOpaque(rgba: []const u8) !void {
try std.testing.expectEqual(@as(u8, 0xff), rgba[alpha]);
}
-fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 {
- const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n";
- var bytes: std.ArrayList(u8) = .empty;
- errdefer bytes.deinit(gpa);
- var offsets: [5]usize = @splat(0);
+const generated_pdf_fragment_max = 256;
- try bytes.appendSlice(gpa, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
- offsets[1] = bytes.items.len;
- try bytes.appendSlice(gpa, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
- offsets[2] = bytes.items.len;
- try bytes.appendSlice(gpa, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
- offsets[3] = bytes.items.len;
- try bytes.appendSlice(gpa, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++
- "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++
- "/Contents 4 0 R >>\nendobj\n");
- offsets[4] = bytes.items.len;
- try bytes.print(gpa, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream });
+const PdfBytes = struct {
+ storage: ?[]u8 = null,
+ len: usize = 0,
- const xref = bytes.items.len;
- try bytes.appendSlice(gpa, "xref\n0 5\n0000000000 65535 f \n");
- for (offsets[1..]) |offset|
- try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset});
- try bytes.print(gpa, "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
- return bytes.toOwnedSlice(gpa);
-}
+ fn appendSlice(bytes: *PdfBytes, source: []const u8) !void {
+ const end = std.math.add(usize, bytes.len, source.len) catch
+ return error.GeneratedPdfTooLarge;
+ if (bytes.storage) |storage| {
+ if (end > storage.len) return error.GeneratedPdfTooLarge;
+ @memcpy(storage[bytes.len..end], source);
+ }
+ bytes.len = end;
+ }
-fn beginPdfObject(
- bytes: *std.ArrayList(u8),
+ fn print(bytes: *PdfBytes, comptime format: []const u8, args: anytype) !void {
+ var fragment: [generated_pdf_fragment_max]u8 = undefined;
+ const rendered = std.fmt.bufPrint(&fragment, format, args) catch
+ return error.GeneratedPdfFragmentTooLong;
+ try bytes.appendSlice(rendered);
+ }
+};
+
+fn allocateGeneratedPdf(
gpa: std.mem.Allocator,
offsets: []usize,
- number: usize,
-) !void {
- offsets[number] = bytes.items.len;
- try bytes.print(gpa, "{d} 0 obj\n", .{number});
+ context: anytype,
+ comptime emit: anytype,
+) ![]u8 {
+ @memset(offsets, 0);
+ var counter: PdfBytes = .{};
+ try emit(&counter, offsets, context);
+
+ const result = try gpa.alloc(u8, counter.len);
+ errdefer gpa.free(result);
+ @memset(offsets, 0);
+ var bytes: PdfBytes = .{ .storage = result };
+ try emit(&bytes, offsets, context);
+ std.debug.assert(bytes.len == result.len);
+ return result;
}
-fn finishGeneratedPdf(
- bytes: *std.ArrayList(u8),
- gpa: std.mem.Allocator,
- offsets: []const usize,
-) ![]u8 {
- const xref = bytes.items.len;
- try bytes.print(gpa, "xref\n0 {d}\n0000000000 65535 f \n", .{offsets.len});
+fn beginPdfObject(bytes: *PdfBytes, offsets: []usize, number: usize) !void {
+ offsets[number] = bytes.len;
+ try bytes.print("{d} 0 obj\n", .{number});
+}
+
+fn finishGeneratedPdf(bytes: *PdfBytes, offsets: []const usize) !void {
+ const xref = bytes.len;
+ try bytes.print("xref\n0 {d}\n0000000000 65535 f \n", .{offsets.len});
for (offsets[1..]) |offset|
- try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset});
+ try bytes.print("{d:0>10} 00000 n \n", .{offset});
try bytes.print(
- gpa,
"trailer\n<< /Size {d} /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n",
.{ offsets.len, xref },
);
- return bytes.toOwnedSlice(gpa);
}
-fn makeOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
- var bytes: std.ArrayList(u8) = .empty;
- errdefer bytes.deinit(gpa);
- var offsets: [12]usize = @splat(0);
+fn emitOffsetRotatedPdf(bytes: *PdfBytes, offsets: []usize, stream: []const u8) !void {
+ try bytes.appendSlice("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 3);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++
+ "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++
+ "/Contents 4 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 4);
+ try bytes.print("<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream });
+ try finishGeneratedPdf(bytes, offsets);
+}
- try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
- try beginPdfObject(&bytes, gpa, &offsets, 1);
- try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /PageMode /UseOutlines >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, &offsets, 2);
- try bytes.appendSlice(gpa, "<< /Type /Pages /Count 3 /Kids [3 0 R 4 0 R 5 0 R] >>\nendobj\n");
+fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 {
+ const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n";
+ var offsets: [5]usize = undefined;
+ return allocateGeneratedPdf(gpa, &offsets, stream, emitOffsetRotatedPdf);
+}
+
+fn emitOutlinePdf(bytes: *PdfBytes, offsets: []usize, _: void) !void {
+ try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /PageMode /UseOutlines >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 3 /Kids [3 0 R 4 0 R 5 0 R] >>\nendobj\n");
for (3..6) |page| {
- try beginPdfObject(&bytes, gpa, &offsets, page);
- try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, page);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n");
}
- try beginPdfObject(&bytes, gpa, &offsets, 6);
- try bytes.appendSlice(gpa, "<< >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, &offsets, 7);
- try bytes.appendSlice(gpa, "<< /Type /Outlines /First 8 0 R /Last 11 0 R /Count 4 >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 6);
+ try bytes.appendSlice("<< >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 7);
+ try bytes.appendSlice("<< /Type /Outlines /First 8 0 R /Last 11 0 R /Count 4 >>\nendobj\n");
// Destinationless branch with a deliberately missing /Title.
- try beginPdfObject(&bytes, gpa, &offsets, 8);
- try bytes.appendSlice(gpa, "<< /Parent 7 0 R /First 9 0 R /Last 9 0 R /Next 10 0 R /Count 1 >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 8);
+ try bytes.appendSlice("<< /Parent 7 0 R /First 9 0 R /Last 9 0 R /Next 10 0 R /Count 1 >>\nendobj\n");
// UTF-16BE "Café 子", with bold/italic + color metadata.
- try beginPdfObject(&bytes, gpa, &offsets, 9);
- try bytes.appendSlice(gpa, "<< /Title <FEFF00430061006600E900205B50> /Parent 8 0 R " ++
+ try beginPdfObject(bytes, offsets, 9);
+ try bytes.appendSlice("<< /Title <FEFF00430061006600E900205B50> /Parent 8 0 R " ++
"/Dest [4 0 R /XYZ 12 34 null] /F 3 /C [0.2 0.4 0.6] >>\nendobj\n");
// A present empty title and a destination with only one usable axis.
- try beginPdfObject(&bytes, gpa, &offsets, 10);
- try bytes.appendSlice(gpa, "<< /Title () /Parent 7 0 R /Prev 8 0 R /Next 11 0 R " ++
+ try beginPdfObject(bytes, offsets, 10);
+ try bytes.appendSlice("<< /Title () /Parent 7 0 R /Prev 8 0 R /Next 11 0 R " ++
"/Dest [5 0 R /FitH 70] >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, &offsets, 11);
- try bytes.appendSlice(gpa, "<< /Title (External) /Parent 7 0 R /Prev 10 0 R " ++
+ try beginPdfObject(bytes, offsets, 11);
+ try bytes.appendSlice("<< /Title (External) /Parent 7 0 R /Prev 10 0 R " ++
"/A << /S /URI /URI (https://example.com/manual) >> >>\nendobj\n");
+ try finishGeneratedPdf(bytes, offsets);
+}
- return finishGeneratedPdf(&bytes, gpa, &offsets);
+fn makeOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
+ var offsets: [12]usize = undefined;
+ return allocateGeneratedPdf(gpa, &offsets, {}, emitOutlinePdf);
}
/// TEST-ONLY generated fixture shared with the core integration tests. It is
@@ -958,81 +1112,79 @@ pub fn makeNoOutlineTestPdf(gpa: std.mem.Allocator) ![]u8 {
return makeOffsetRotatedPdf(gpa);
}
-fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
- const levels = c.PARDES_PDF_MAX_OUTLINE_DEPTH + 1;
+fn emitTooDeepOutlinePdf(bytes: *PdfBytes, offsets: []usize, levels: usize) !void {
const first_outline_item = 5;
- const object_count = first_outline_item + levels;
- const offsets = try gpa.alloc(usize, object_count);
- defer gpa.free(offsets);
- @memset(offsets, 0);
- var bytes: std.ArrayList(u8) = .empty;
- errdefer bytes.deinit(gpa);
-
- try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
- try beginPdfObject(&bytes, gpa, offsets, 1);
- try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 2);
- try bytes.appendSlice(gpa, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 3);
- try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 4);
- try bytes.print(gpa, "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count {d} >>\nendobj\n", .{levels});
+ try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 3);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 4);
+ try bytes.print("<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count {d} >>\nendobj\n", .{levels});
for (0..levels) |level| {
const number = first_outline_item + level;
- try beginPdfObject(&bytes, gpa, offsets, number);
- try bytes.print(gpa, "<< /Title (Level {d}) /Parent {d} 0 R", .{
+ try beginPdfObject(bytes, offsets, number);
+ try bytes.print("<< /Title (Level {d}) /Parent {d} 0 R", .{
level,
if (level == 0) 4 else number - 1,
});
if (level + 1 < levels) {
- try bytes.print(gpa, " /First {d} 0 R /Last {d} 0 R /Count {d}", .{
+ try bytes.print(" /First {d} 0 R /Last {d} 0 R /Count {d}", .{
number + 1,
number + 1,
levels - level - 1,
});
}
- try bytes.appendSlice(gpa, " >>\nendobj\n");
+ try bytes.appendSlice(" >>\nendobj\n");
}
-
- return finishGeneratedPdf(&bytes, gpa, offsets);
+ try finishGeneratedPdf(bytes, offsets);
}
-fn makeWideOutlinePdf(gpa: std.mem.Allocator, items: usize) ![]u8 {
- if (items == 0) return error.EmptyOutlineFixture;
- const first_outline_item = 5;
- const object_count = try std.math.add(usize, first_outline_item, items);
+fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
+ const levels: usize = c.PARDES_PDF_MAX_OUTLINE_DEPTH + 1;
+ const object_count = 5 + levels;
const offsets = try gpa.alloc(usize, object_count);
defer gpa.free(offsets);
- @memset(offsets, 0);
- var bytes: std.ArrayList(u8) = .empty;
- errdefer bytes.deinit(gpa);
+ return allocateGeneratedPdf(gpa, offsets, levels, emitTooDeepOutlinePdf);
+}
- try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
- try beginPdfObject(&bytes, gpa, offsets, 1);
- try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 2);
- try bytes.appendSlice(gpa, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 3);
- try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n");
- try beginPdfObject(&bytes, gpa, offsets, 4);
- try bytes.print(gpa, "<< /Type /Outlines /First 5 0 R /Last {d} 0 R /Count {d} >>\nendobj\n", .{
+fn emitWideOutlinePdf(bytes: *PdfBytes, offsets: []usize, items: usize) !void {
+ const first_outline_item = 5;
+ try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 3);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 4);
+ try bytes.print("<< /Type /Outlines /First 5 0 R /Last {d} 0 R /Count {d} >>\nendobj\n", .{
first_outline_item + items - 1,
items,
});
for (0..items) |ordinal| {
const number = first_outline_item + ordinal;
- try beginPdfObject(&bytes, gpa, offsets, number);
- try bytes.print(gpa, "<< /Title (Section {d}) /Parent 4 0 R /Dest [3 0 R /Fit]", .{ordinal});
+ try beginPdfObject(bytes, offsets, number);
+ try bytes.print("<< /Title (Section {d}) /Parent 4 0 R /Dest [3 0 R /Fit]", .{ordinal});
if (ordinal != 0)
- try bytes.print(gpa, " /Prev {d} 0 R", .{number - 1});
+ try bytes.print(" /Prev {d} 0 R", .{number - 1});
if (ordinal + 1 < items)
- try bytes.print(gpa, " /Next {d} 0 R", .{number + 1});
- try bytes.appendSlice(gpa, " >>\nendobj\n");
+ try bytes.print(" /Next {d} 0 R", .{number + 1});
+ try bytes.appendSlice(" >>\nendobj\n");
}
+ try finishGeneratedPdf(bytes, offsets);
+}
- return finishGeneratedPdf(&bytes, gpa, offsets);
+fn makeWideOutlinePdf(gpa: std.mem.Allocator, items: usize) ![]u8 {
+ if (items == 0) return error.EmptyOutlineFixture;
+ const object_count = try std.math.add(usize, 5, items);
+ const offsets = try gpa.alloc(usize, object_count);
+ defer gpa.free(offsets);
+ return allocateGeneratedPdf(gpa, offsets, items, emitWideOutlinePdf);
}
test "PDF outline is a stable owned DFS view with native destinations" {
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index e0969018..25870f20 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -5,12 +5,17 @@
#include <math.h>
#include <limits.h>
#include <stdint.h>
+#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#define PARDES_PDF_INITIAL_RESULT_QUADS 64
struct pardes_pdf_document {
+ pardes_pdf_allocator allocator;
+ fz_alloc_context mupdf_allocator;
+ int uses_allocator;
+
fz_context *ctx;
fz_document *doc;
int page_count;
@@ -26,6 +31,97 @@ struct pardes_pdf_document {
int display_list_candidate_page_number;
};
+static pardes_pdf_allocator pardes_pdf_global_allocator;
+static int pardes_pdf_allocator_installed;
+static _Atomic size_t pardes_pdf_active_documents;
+static atomic_flag pardes_pdf_allocator_lock = ATOMIC_FLAG_INIT;
+
+static void
+pardes_pdf_lock_allocator(void)
+{
+ while (atomic_flag_test_and_set_explicit(
+ &pardes_pdf_allocator_lock, memory_order_acquire))
+ ;
+}
+
+static void
+pardes_pdf_unlock_allocator(void)
+{
+ atomic_flag_clear_explicit(
+ &pardes_pdf_allocator_lock, memory_order_release);
+}
+
+
+int
+pardes_pdf_set_allocator(const pardes_pdf_allocator *allocator)
+{
+ int status = PARDES_PDF_OK;
+
+ if (allocator != NULL &&
+ (allocator->alloc == NULL || allocator->realloc == NULL ||
+ allocator->free == NULL))
+ return PARDES_PDF_ERROR;
+
+ pardes_pdf_lock_allocator();
+ if (atomic_load(&pardes_pdf_active_documents) != 0) {
+ status = PARDES_PDF_ERROR;
+ } else if (allocator == NULL) {
+ memset(&pardes_pdf_global_allocator, 0,
+ sizeof(pardes_pdf_global_allocator));
+ pardes_pdf_allocator_installed = 0;
+ } else {
+ pardes_pdf_global_allocator = *allocator;
+ pardes_pdf_allocator_installed = 1;
+ }
+ pardes_pdf_unlock_allocator();
+ return status;
+}
+
+static pardes_pdf_document *
+pardes_pdf_allocate_document(void)
+{
+ pardes_pdf_document *document;
+ pardes_pdf_allocator allocator;
+ int uses_allocator;
+
+ pardes_pdf_lock_allocator();
+ uses_allocator = pardes_pdf_allocator_installed;
+ allocator = pardes_pdf_global_allocator;
+ atomic_fetch_add(&pardes_pdf_active_documents, 1);
+ pardes_pdf_unlock_allocator();
+
+ if (uses_allocator) {
+ document = (pardes_pdf_document *)allocator.alloc(
+ allocator.user, sizeof(*document));
+ } else {
+ document = (pardes_pdf_document *)calloc(1, sizeof(*document));
+ }
+ if (document == NULL) {
+ atomic_fetch_sub(&pardes_pdf_active_documents, 1);
+ return NULL;
+ }
+ if (!uses_allocator)
+ return document;
+
+ memset(document, 0, sizeof(*document));
+ document->allocator = allocator;
+ document->uses_allocator = 1;
+ document->mupdf_allocator.user = allocator.user;
+ document->mupdf_allocator.malloc = allocator.alloc;
+ document->mupdf_allocator.realloc = allocator.realloc;
+ document->mupdf_allocator.free = allocator.free;
+ return document;
+}
+
+static void
+pardes_pdf_free_document(pardes_pdf_document *document)
+{
+ if (document->uses_allocator)
+ document->allocator.free(document->allocator.user, document);
+ else
+ free(document);
+}
+
static void
pardes_pdf_drop_cached_page(pardes_pdf_document *document)
{
@@ -230,15 +326,19 @@ pardes_pdf_open(const char *path, int *page_count)
if (path == NULL || page_count == NULL)
return NULL;
- state = (pardes_pdf_document *)calloc(1, sizeof(*state));
+ state = pardes_pdf_allocate_document();
if (state == NULL)
return NULL;
state->cached_page_number = -1;
state->display_list_candidate_page_number = -1;
- ctx = fz_new_context(NULL, NULL, PARDES_PDF_STORE_LIMIT_BYTES);
+ ctx = fz_new_context(
+ state->uses_allocator ? &state->mupdf_allocator : NULL,
+ NULL,
+ PARDES_PDF_STORE_LIMIT_BYTES);
if (ctx == NULL) {
- free(state);
+ pardes_pdf_free_document(state);
+ atomic_fetch_sub(&pardes_pdf_active_documents, 1);
return NULL;
}
@@ -256,7 +356,8 @@ pardes_pdf_open(const char *path, int *page_count)
fz_report_error(ctx);
fz_drop_document(ctx, doc);
fz_drop_context(ctx);
- free(state);
+ pardes_pdf_free_document(state);
+ atomic_fetch_sub(&pardes_pdf_active_documents, 1);
return NULL;
}
@@ -275,7 +376,8 @@ pardes_pdf_close(pardes_pdf_document *document)
pardes_pdf_drop_cached_page(document);
fz_drop_document(document->ctx, document->doc);
fz_drop_context(document->ctx);
- free(document);
+ pardes_pdf_free_document(document);
+ atomic_fetch_sub(&pardes_pdf_active_documents, 1);
}
int
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index bda1eea1..40eaa46c 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -14,6 +14,27 @@ extern "C" {
*/
typedef struct pardes_pdf_document pardes_pdf_document;
+typedef void *(*pardes_pdf_alloc_fn)(void *user, size_t size);
+typedef void *(*pardes_pdf_realloc_fn)(
+ void *user,
+ void *pointer,
+ size_t size
+);
+typedef void (*pardes_pdf_free_fn)(void *user, void *pointer);
+
+typedef struct pardes_pdf_allocator {
+ void *user;
+ pardes_pdf_alloc_fn alloc;
+ pardes_pdf_realloc_fn realloc;
+ pardes_pdf_free_fn free;
+} pardes_pdf_allocator;
+
+/*
+ * Install one process-global allocator, or restore libc/default MuPDF
+ * allocation by passing NULL. Changing it while a document is live fails.
+ */
+int pardes_pdf_set_allocator(const pardes_pdf_allocator *allocator);
+
typedef struct pardes_pdf_raster_layout {
int width;
int height;
diff --git a/src/syntax.zig b/src/syntax.zig
index 43e370d5..a745b678 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -5,6 +5,7 @@
const std = @import("std");
const config = @import("pardes_config");
const tracy = @import("tracy.zig");
+const grammar_manifest = @import("grammar_manifest.zig");
pub const enabled = config.syntax_highlighting;
const zig_grammar = config.syntax_zig_grammar;
@@ -17,32 +18,7 @@ const ts = if (enabled) @import("tree-sitter") else struct {
};
const ts_queries = if (enabled) @import("ts_queries") else struct {};
-extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
-extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
-extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_css() callconv(.c) *const ts.Language;
-extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
-extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
-extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
-extern fn tree_sitter_go() callconv(.c) *const ts.Language;
-extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_html() callconv(.c) *const ts.Language;
-extern fn tree_sitter_java() callconv(.c) *const ts.Language;
-extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
-extern fn tree_sitter_json() callconv(.c) *const ts.Language;
-extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
-extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
-extern fn tree_sitter_php() callconv(.c) *const ts.Language;
-extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_python() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
-extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
-extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
-extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
+const LanguageFn = *const fn () callconv(.c) *const ts.Language;
pub const Syn = enum(u8) { none, keyword, string, number, comment };
@@ -53,40 +29,115 @@ const Spec = struct {
compiled_query: ?*ts.Query = null,
};
-var zig_specs = if (zig_grammar) [_]Spec{
- .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
-} else [_]Spec{};
+fn grammarSelected(comptime g: grammar_manifest.Grammar) bool {
+ return switch (g.tier) {
+ .zig => zig_grammar,
+ .minimal => minimal_grammars,
+ .full => full_grammars,
+ };
+}
+
+fn specCount() comptime_int {
+ var count = 0;
+ for (grammar_manifest.all) |g| {
+ if (grammarSelected(g)) count += 1;
+ }
+ return count;
+}
+
+fn initSpecs() [specCount()]Spec {
+ var out: [specCount()]Spec = undefined;
+ var i = 0;
+ inline for (grammar_manifest.all) |g| {
+ if (grammarSelected(g)) {
+ out[i] = .{
+ .exts = g.exts,
+ .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }),
+ .query_src = @field(ts_queries, g.name ++ "_highlights"),
+ };
+ i += 1;
+ }
+ }
+ return out;
+}
+
+var specs = initSpecs();
+
+const allocation_header_size = 16;
+var syntax_allocator: std.mem.Allocator = undefined;
+var syntax_started = false;
+
+extern fn ts_set_allocator(
+ new_malloc: ?*const fn (size: usize) callconv(.c) ?*anyopaque,
+ new_calloc: ?*const fn (nmemb: usize, size: usize) callconv(.c) ?*anyopaque,
+ new_realloc: ?*const fn (ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque,
+ new_free: ?*const fn (ptr: ?*anyopaque) callconv(.c) void,
+) void;
+
+fn syntaxAlloc(size_arg: usize) callconv(.c) ?*anyopaque {
+ const size = @max(size_arg, 1);
+ const total = std.math.add(usize, allocation_header_size, size) catch return null;
+ const bytes = syntax_allocator.alignedAlloc(u8, .@"16", total) catch return null;
+ const header: *align(16) usize = @ptrCast(bytes.ptr);
+ header.* = total;
+ return @ptrCast(bytes.ptr + allocation_header_size);
+}
+
+fn syntaxCalloc(count: usize, size: usize) callconv(.c) ?*anyopaque {
+ const len = std.math.mul(usize, count, size) catch return null;
+ const pointer = syntaxAlloc(len) orelse return null;
+ const bytes: [*]u8 = @ptrCast(pointer);
+ @memset(bytes[0..len], 0);
+ return pointer;
+}
+
+fn syntaxRealloc(ptr: ?*anyopaque, new_size: usize) callconv(.c) ?*anyopaque {
+ const pointer = ptr orelse return syntaxAlloc(new_size);
+ if (new_size == 0) {
+ syntaxFree(pointer);
+ return null;
+ }
+ const user: [*]u8 = @ptrCast(pointer);
+ const header: *align(16) usize = @ptrCast(@alignCast(user - allocation_header_size));
+ const old_total = header.*;
+ const old_bytes: []align(16) u8 = @as([*]align(16) u8, @ptrCast(header))[0..old_total];
+ const new_total = std.math.add(usize, allocation_header_size, new_size) catch return null;
+ const new_bytes = syntax_allocator.realloc(old_bytes, new_total) catch return null;
+ const new_header: *align(16) usize = @ptrCast(new_bytes.ptr);
+ new_header.* = new_total;
+ return @ptrCast(new_bytes.ptr + allocation_header_size);
+}
+
+fn syntaxFree(ptr: ?*anyopaque) callconv(.c) void {
+ const pointer = ptr orelse return;
+ const user: [*]u8 = @ptrCast(pointer);
+ const header: *align(16) usize = @ptrCast(@alignCast(user - allocation_header_size));
+ const total = header.*;
+ const bytes: []align(16) u8 = @as([*]align(16) u8, @ptrCast(header))[0..total];
+ syntax_allocator.free(bytes);
+}
-var minimal_specs = if (minimal_grammars) [_]Spec{
- .{ .exts = &.{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
- .{ .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
-} else [_]Spec{};
+pub fn start(gpa: std.mem.Allocator) void {
+ if (comptime enabled) {
+ std.debug.assert(!syntax_started);
+ syntax_allocator = gpa;
+ syntax_started = true;
+ ts_set_allocator(syntaxAlloc, syntaxCalloc, syntaxRealloc, syntaxFree);
+ }
+}
-var full_specs = if (full_grammars) [_]Spec{
- .{ .exts = &.{ ".adb", ".ads", ".ada" }, .language = tree_sitter_ada, .query_src = ts_queries.ada_highlights },
- .{ .exts = &.{ ".sh", ".bash", ".zsh" }, .language = tree_sitter_bash, .query_src = ts_queries.bash_highlights },
- .{ .exts = &.{ ".cs", ".csx" }, .language = tree_sitter_c_sharp, .query_src = ts_queries.c_sharp_highlights },
- .{ .exts = &.{ ".clj", ".cljs", ".cljc", ".edn" }, .language = tree_sitter_clojure, .query_src = ts_queries.clojure_highlights },
- .{ .exts = &.{".css"}, .language = tree_sitter_css, .query_src = ts_queries.css_highlights },
- .{ .exts = &.{ ".ex", ".exs" }, .language = tree_sitter_elixir, .query_src = ts_queries.elixir_highlights },
- .{ .exts = &.{ ".erl", ".hrl" }, .language = tree_sitter_erlang, .query_src = ts_queries.erlang_highlights },
- .{ .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .language = tree_sitter_fortran, .query_src = ts_queries.fortran_highlights },
- .{ .exts = &.{".go"}, .language = tree_sitter_go, .query_src = ts_queries.go_highlights },
- .{ .exts = &.{ ".hs", ".lhs" }, .language = tree_sitter_haskell, .query_src = ts_queries.haskell_highlights },
- .{ .exts = &.{ ".html", ".htm" }, .language = tree_sitter_html, .query_src = ts_queries.html_highlights },
- .{ .exts = &.{".java"}, .language = tree_sitter_java, .query_src = ts_queries.java_highlights },
- .{ .exts = &.{ ".js", ".jsx", ".mjs", ".cjs" }, .language = tree_sitter_javascript, .query_src = ts_queries.javascript_highlights },
- .{ .exts = &.{".json"}, .language = tree_sitter_json, .query_src = ts_queries.json_highlights },
- .{ .exts = &.{ ".kt", ".kts" }, .language = tree_sitter_kotlin, .query_src = ts_queries.kotlin_highlights },
- .{ .exts = &.{ ".ml", ".mli" }, .language = tree_sitter_ocaml, .query_src = ts_queries.ocaml_highlights },
- .{ .exts = &.{ ".pas", ".pp", ".p" }, .language = tree_sitter_pascal, .query_src = ts_queries.pascal_highlights },
- .{ .exts = &.{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .language = tree_sitter_php, .query_src = ts_queries.php_highlights },
- .{ .exts = &.{ ".ps1", ".psm1", ".psd1" }, .language = tree_sitter_powershell, .query_src = ts_queries.powershell_highlights },
- .{ .exts = &.{ ".py", ".pyw" }, .language = tree_sitter_python, .query_src = ts_queries.python_highlights },
- .{ .exts = &.{ ".rb", ".rake" }, .language = tree_sitter_ruby, .query_src = ts_queries.ruby_highlights },
- .{ .exts = &.{".rs"}, .language = tree_sitter_rust, .query_src = ts_queries.rust_highlights },
- .{ .exts = &.{ ".scala", ".sc" }, .language = tree_sitter_scala, .query_src = ts_queries.scala_highlights },
-} else [_]Spec{};
+pub fn stop() void {
+ if (comptime enabled) {
+ std.debug.assert(syntax_started);
+ for (&specs) |*spec| {
+ if (spec.compiled_query) |query| query.destroy();
+ spec.compiled_query = null;
+ }
+ ts_set_allocator(null, null, null, null);
+ syntax_started = false;
+ syntax_allocator = undefined;
+ }
+}
const Selected = struct { lang: *const ts.Language, query: *ts.Query };
@@ -102,17 +153,7 @@ fn ensure(spec: *Spec) !Selected {
}
fn forExt(ext: []const u8) !?Selected {
- for (&zig_specs) |*spec| {
- for (spec.exts) |choice| {
- if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
- }
- }
- for (&minimal_specs) |*spec| {
- for (spec.exts) |choice| {
- if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
- }
- }
- for (&full_specs) |*spec| {
+ for (&specs) |*spec| {
for (spec.exts) |choice| {
if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
}
@@ -183,3 +224,22 @@ pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []c
}
return styles;
}
+
+test "tree-sitter allocator callbacks preserve and free exact allocations" {
+ syntax_allocator = std.testing.allocator;
+ defer syntax_allocator = undefined;
+
+ var live: ?*anyopaque = syntaxCalloc(4, 1) orelse return error.OutOfMemory;
+ defer if (live) |pointer| syntaxFree(pointer);
+ const original: [*]u8 = @ptrCast(live.?);
+ try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, original[0..4]);
+ @memcpy(original[0..4], "data");
+
+ live = syntaxRealloc(live, 32) orelse return error.OutOfMemory;
+ const grown: [*]u8 = @ptrCast(live.?);
+ try std.testing.expectEqualSlices(u8, "data", grown[0..4]);
+
+ try std.testing.expect(syntaxCalloc(std.math.maxInt(usize), 2) == null);
+ try std.testing.expect(syntaxRealloc(live, 0) == null);
+ live = null;
+}
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 47123ceb..5e50e15d 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -35,14 +35,18 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
const arena = p.scratch.allocator();
const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- var ls: std.ArrayList([]const u8) = .empty;
- var it = std.mem.splitAny(u8, full, "\n");
+ // split yields one more item than delimiters; the extra final slot is the
+ // cursor row retained below.
+ const rows = try arena.alloc([]const u8, std.mem.count(u8, full, "\n") + 2);
+ var n: usize = 0;
+ var it = std.mem.splitScalar(u8, full, '\n');
while (it.next()) |raw| {
const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
- try ls.append(arena, if (is_prompt) "" else raw);
+ rows[n] = if (is_prompt) "" else raw;
+ n += 1;
}
- try ls.append(arena, "");
- return ls.items;
+ rows[n] = "";
+ return rows;
}
/// The body a terminal renders: the viewport's shell rows (prompt rows blanked
@@ -51,21 +55,35 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
const raw = try pane.vt.plainString(arena);
var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
- var vp: std.ArrayList([]const u8) = .empty;
+ const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
var lines = std.mem.splitScalar(u8, raw, '\n');
+ var n: usize = 0;
while (lines.next()) |ln| {
const is_prompt = if (pane.mode != .tty)
if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
else
false;
- try vp.append(arena, if (is_prompt) "" else ln);
+ vp[n] = if (is_prompt) "" else ln;
+ n += 1;
}
+ std.debug.assert(n == vp.len);
+
+ const len = fillBody(null, pane, vp);
+ const out = try arena.alloc(u8, len);
+ const filled = fillBody(out, pane, vp);
+ std.debug.assert(filled == out.len);
+ return out;
+}
+
+/// Run the terminal body row walk. A null destination counts bytes; a slice
+/// fills the exact allocation made from that count.
+fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
const off = pane.scroll();
var g: i32 = pane.gridRow(off);
// the buffer can start above the viewport: drop the lines scrolled past
var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0;
- var out: std.ArrayList(u8) = .empty;
+ var written: usize = 0;
var n: usize = 0;
while (n < pane.rows) {
if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) {
@@ -74,21 +92,32 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
while (bit.next()) |ln| : (k += 1) {
if (k < skip) continue;
if (n >= pane.rows) break;
- if (n > 0) try out.append(arena, '\n');
- try out.appendSlice(arena, ln);
+ if (n > 0) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
+ if (dst) |out| @memcpy(out[written..][0..ln.len], ln);
+ written += ln.len;
n += 1;
}
skip = 0;
g += o.rows;
continue;
};
- if (n > 0) try out.append(arena, '\n');
+ if (n > 0) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
const vi = g - goff;
- if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]);
+ if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) {
+ const line = viewport[@intCast(vi)];
+ if (dst) |out| @memcpy(out[written..][0..line.len], line);
+ written += line.len;
+ }
n += 1;
g += 1;
}
- return out.items;
+ return written;
}
/// tty colors: recolor each body cell from the emulator's own style so shell
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index ad6f04b0..e8e3c1bd 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -37,7 +37,7 @@ pub const Command = struct {
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
paste: []const u8,
- /// a language query finished on a worker; rows are gpa-owned
+ /// a language query finished on a worker; rows are lsp-domain-owned
lsp_done: struct { id: u32, rows: []u8 },
/// a selection-filter worker finished; every stdout is gpa-owned
pipe_done: selection_pipe.Response,
@@ -83,20 +83,51 @@ const LspJob = struct {
arg: []u8,
root: []u8,
- fn free(j: *LspJob, gpa: std.mem.Allocator) void {
- gpa.free(j.path);
- gpa.free(j.source);
- gpa.free(j.arg);
- gpa.free(j.root);
- gpa.destroy(j);
+ fn free(j: *LspJob, allocator: std.mem.Allocator) void {
+ allocator.free(j.path);
+ allocator.free(j.source);
+ allocator.free(j.arg);
+ allocator.free(j.root);
+ allocator.destroy(j);
}
};
+const max_pipe_tasks = 16;
+
const PipeTask = struct {
id: u32,
future: std.Io.Future(anyerror!void),
};
+const PipeTasks = struct {
+ items: [max_pipe_tasks]PipeTask = undefined,
+ len: usize = 0,
+
+ fn full(tasks: *const PipeTasks) bool {
+ return tasks.len == tasks.items.len;
+ }
+
+ fn add(tasks: *PipeTasks, task: PipeTask) void {
+ std.debug.assert(!tasks.full());
+ tasks.items[tasks.len] = task;
+ tasks.len += 1;
+ }
+
+ fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void {
+ for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) {
+ task.future.await(io) catch {};
+ tasks.len -= 1;
+ std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]);
+ return;
+ };
+ }
+
+ fn cancelAll(tasks: *PipeTasks, io: std.Io) void {
+ for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {};
+ tasks.len = 0;
+ }
+};
+
const Pty = struct {
file: std.Io.File,
pid: posix.pid_t,
@@ -338,6 +369,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const io = init.io;
const gpa = init.gpa;
+ const allocs = pardes.allocators.init(gpa);
+ defer pardes.allocators.deinit();
+ var options = opts;
+ options.image_allocator = allocs.image;
+ options.pdf_allocator = allocs.pdf;
+ options.tree_sitter_allocator = allocs.tree_sitter;
+
// SIGWINCH must never run vaxis's signal handler: it posts the winsize
// event through std.Io.Mutex/Condition, and when the signal lands on a
// thread blocked inside an Io.Threaded syscall region (pty readers in
@@ -366,20 +404,25 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// clicks there (see test/snapshots/ghostty-mid.snap).
try vx.setMouseMode(tty.writer(), true);
- var frame_arena: std.heap.ArenaAllocator = .init(gpa);
- defer frame_arena.deinit();
-
- // stb_image allocator for image panes
- pardes.image.start(io, gpa);
- defer pardes.image.stop();
+ pardes.image.start(io, allocs.image);
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ pardes.syntax.start(allocs.tree_sitter);
+ defer {
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ }
- var core = if (opts.load_path) |lp| blk: {
+ var core = if (options.load_path) |lp| blk: {
const bytes = try look.readFile(gpa, lp);
defer gpa.free(bytes);
- break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
- } else try pardes.Pardes.init(gpa, opts);
+ break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes);
+ } else try pardes.Pardes.init(allocs.pardes, options);
defer core.deinit();
+ var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
+ defer frame_arena.deinit();
+
// shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
@@ -401,7 +444,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var lsp_task: ?std.Io.Future(anyerror!void) = null;
// Selection filters may overlap: a second submit supersedes the first in
// core without synchronously canceling a possibly slow shell command.
- var pipe_tasks: std.ArrayList(PipeTask) = .empty;
+ var pipe_tasks: PipeTasks = .{};
// One inotify instance for every watched pane, opened here — before any
// thread exists — so the pre-loop effect drain below can already mark the
// file a positional path argument opened. -1 off linux: watchPane goes
@@ -426,8 +469,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
t.cancel(io) catch {};
lsp_task = null;
}
- for (pipe_tasks.items) |*task| task.future.cancel(io) catch {};
- pipe_tasks.deinit(gpa);
+ pipe_tasks.cancelAll(io);
// same contract as the pty readers: cancel unblocks the watcher's
// read, and only then is the fd safe to close
if (watch_task) |*t| {
@@ -442,7 +484,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.pty_read => |pr| gpa.free(pr.bytes),
.command => |line| gpa.free(line),
.paste => |b| gpa.free(@constCast(b)),
- .lsp_done => |d| gpa.free(d.rows),
+ .lsp_done => |d| allocs.lsp.free(d.rows),
.pipe_done => |response_value| {
var response = response_value;
response.deinit(gpa);
@@ -457,12 +499,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
// their own session.
- const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ const sock_fd: c_int = if (options.nested) -1 else nested.listen();
defer nested.unlisten(sock_fd);
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
- drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
+ drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
try loop.start();
defer loop.stop();
@@ -621,7 +663,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.files_changed => check_files = true,
.lsp_done => |d| {
core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
- gpa.free(d.rows);
+ allocs.lsp.free(d.rows);
// the worker is finished; join it so its future does not
// leak (same contract as pty_eof above)
if (lsp_task) |*t| {
@@ -637,11 +679,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.outputs = response.outputs,
} });
response.deinit(gpa);
- for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) {
- task.future.await(io) catch {};
- _ = pipe_tasks.orderedRemove(i);
- break;
- };
+ pipe_tasks.finish(io, response_value.id);
},
}
batch += 1;
@@ -679,7 +717,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
}
}
- drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
+ drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; the live
@@ -691,7 +729,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var o = core.opts;
o.cols = core.screen_w;
o.rows = core.screen_h; // pre-size: dump panes never greet
- const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk;
+ const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk;
for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| {
pt.reader.cancel(io) catch {};
_ = libc.close(pt.file.handle);
@@ -811,14 +849,20 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// Toggling PETSCII or closing a pane removes its attachment from the
// Surface. Release the terminal-side image then, not merely when that
// numeric pane slot happens to be reused.
- var stale: std.ArrayList(pardes.ImageCacheKey) = .empty;
- defer stale.deinit(gpa);
- var image_iterator = kitty_handles.iterator();
- while (image_iterator.next()) |entry|
- if (!surfaceHasKittyKey(surface, entry.key_ptr.*))
- stale.append(gpa, entry.key_ptr.*) catch {};
- for (stale.items) |key| if (kitty_handles.fetchRemove(key)) |removed|
- vx.freeImage(tty.writer(), removed.value.id);
+ while (true) {
+ var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined;
+ var stale_len: usize = 0;
+ var image_iterator = kitty_handles.iterator();
+ while (image_iterator.next()) |entry| {
+ if (surfaceHasKittyKey(surface, entry.key_ptr.*)) continue;
+ stale[stale_len] = entry.key_ptr.*;
+ stale_len += 1;
+ if (stale_len == stale.len) break;
+ }
+ for (stale[0..stale_len]) |key| if (kitty_handles.fetchRemove(key)) |removed|
+ vx.freeImage(tty.writer(), removed.value.id);
+ if (stale_len < stale.len) break;
+ }
if (surface.cursor) |cur| {
win.showCursor(cur.x, cur.y);
// insert = beam, everything else = the terminal's default shape
@@ -847,11 +891,12 @@ fn drainEffects(
gens: *[pardes.MAX_PANES]u32,
io: std.Io,
gpa: std.mem.Allocator,
+ lsp_allocator: std.mem.Allocator,
loop: *Loop,
vx: *vaxis.Vaxis,
tty: *vaxis.Tty,
lsp_task: *?std.Io.Future(anyerror!void),
- pipe_tasks: *std.ArrayList(PipeTask),
+ pipe_tasks: *PipeTasks,
inotify_fd: c_int,
watches: *[pardes.MAX_PANES]?Watch,
threads_ok: bool,
@@ -944,31 +989,31 @@ fn drainEffects(
// a pane with no file still asks `status` (it is about the backend,
// not the buffer): empty path and source, root from the pane's cwd
const f = pane.file;
- const job = gpa.create(LspJob) catch continue;
+ const job = lsp_allocator.create(LspJob) catch continue;
job.* = .{
.id = q.id,
.kind = q.kind,
.offset = q.offset,
- .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch {
- gpa.destroy(job);
+ .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch {
+ lsp_allocator.destroy(job);
continue;
},
- .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch {
- gpa.free(job.path);
- gpa.destroy(job);
+ .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.destroy(job);
continue;
},
- .arg = gpa.dupe(u8, q.arg.slice()) catch {
- gpa.free(job.path);
- gpa.free(job.source);
- gpa.destroy(job);
+ .arg = lsp_allocator.dupe(u8, q.arg.slice()) catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.free(job.source);
+ lsp_allocator.destroy(job);
continue;
},
- .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
- gpa.free(job.path);
- gpa.free(job.source);
- gpa.free(job.arg);
- gpa.destroy(job);
+ .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
+ lsp_allocator.free(job.path);
+ lsp_allocator.free(job.source);
+ lsp_allocator.free(job.arg);
+ lsp_allocator.destroy(job);
continue;
},
};
@@ -981,24 +1026,24 @@ fn drainEffects(
old.cancel(io) catch {};
lsp_task.* = null;
}
- lsp_task.* = io.concurrent(lspWorker, .{ gpa, job, loop }) catch {
- job.free(gpa);
+ lsp_task.* = io.concurrent(lspWorker, .{ lsp_allocator, job, loop }) catch {
+ job.free(lsp_allocator);
continue;
};
},
.pipe => |request| {
if (!threads_ok) continue;
+ if (pipe_tasks.full()) {
+ core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } });
+ continue;
+ }
const view = core.pipeRequest(request.id) orelse continue;
const job = selection_pipe.Job.copy(gpa, view) catch continue;
- pipe_tasks.ensureUnusedCapacity(gpa, 1) catch {
- job.deinit(gpa);
- continue;
- };
const future = io.concurrent(pipeWorker, .{ io, gpa, job, loop }) catch {
job.deinit(gpa);
continue;
};
- pipe_tasks.appendAssumeCapacity(.{ .id = request.id, .future = future });
+ pipe_tasks.add(.{ .id = request.id, .future = future });
},
.watch => |w| {
// starting, the path and the on-disk bytes are read off the core
@@ -1019,14 +1064,14 @@ fn drainEffects(
/// the whole async execution model: the same shape as readPty — do the slow
/// thing on a worker, hand the result to the loop as an event, let the core
/// stay a state machine that never blocks.
-fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
- defer job.free(gpa);
- var arena: std.heap.ArenaAllocator = .init(gpa);
+fn lspWorker(allocator: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
+ defer job.free(allocator);
+ var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
- // the shell owns the result buffer; the backend only ever writes to it
- var out: std.Io.Writer.Allocating = .init(gpa);
+ // The shell owns the result buffer; the backend only ever writes to it.
+ var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
- pardes.lsp.query(gpa, arena.allocator(), .{
+ pardes.lsp.query(allocator, arena.allocator(), .{
.kind = job.kind,
.path = job.path,
.source = job.source,
@@ -1034,8 +1079,8 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void {
.arg = job.arg,
.root = job.root,
}, &out.writer);
- const rows = gpa.dupe(u8, out.written()) catch return;
- loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows);
+ const rows = allocator.dupe(u8, out.written()) catch return;
+ loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch allocator.free(rows);
return;
}
diff --git a/src/web.zig b/src/web.zig
index 43b6c96d..e17e147e 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -96,14 +96,45 @@ comptime {
const State = struct {
core: *pardes.Pardes,
arena: std.heap.ArenaAllocator,
- cells: std.ArrayList(WebCell) = .empty,
- effect_data: std.ArrayList(u8) = .empty,
+ cells: []WebCell = &.{},
+ frame_len: usize = 0,
+ frame_cols: u16 = 0,
+ frame_rows: u16 = 0,
+ effect_data: []u8 = &.{},
+ effect_len: usize = 0,
+
+ fn init(cols: u16, rows: u16) !State {
+ const allocs = pardes.allocators.init(gpa);
+ errdefer pardes.allocators.deinit();
+ pardes.image.start(std.Io.failing, allocs.image);
+ errdefer pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.start(allocs.tree_sitter);
+ errdefer pardes.syntax.stop();
+
+ const core = try pardes.Pardes.initFromDump(allocs.pardes, .{
+ .cols = cols,
+ .rows = rows,
+ .image_allocator = allocs.image,
+ .pdf_allocator = allocs.pdf,
+ .tree_sitter_allocator = allocs.tree_sitter,
+ }, embedded_dump);
+ return .{
+ .core = core,
+ .arena = .init(allocs.frame),
+ };
+ }
fn deinit(s: *State) void {
- s.core.deinit();
+ if (s.cells.len > 0) gpa.free(s.cells);
+ if (s.effect_data.len > 0) gpa.free(s.effect_data);
s.arena.deinit();
- s.cells.deinit(gpa);
- s.effect_data.deinit(gpa);
+ s.core.deinit();
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ pardes.allocators.deinit();
}
};
@@ -120,12 +151,11 @@ export fn pardes_init(cols_arg: u32, rows_arg: u32) u32 {
const cols: u16 = @intCast(std.math.clamp(cols_arg, 1, std.math.maxInt(u16)));
const rows: u16 = @intCast(std.math.clamp(rows_arg, 1, std.math.maxInt(u16)));
- const core = pardes.Pardes.initFromDump(gpa, .{ .cols = cols, .rows = rows }, embedded_dump) catch |err| {
+ state = State.init(cols, rows) catch |err| {
last_error = @intFromError(err);
return last_error;
};
- state = .{ .core = core, .arena = .init(gpa) };
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ state.?.core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
return 0;
}
@@ -284,16 +314,33 @@ export fn pardes_tick() void {
export fn pardes_frame() u32 {
const s = &(state orelse return 0);
_ = s.arena.reset(.retain_capacity);
+ s.frame_len = 0;
+ s.frame_cols = 0;
+ s.frame_rows = 0;
const surface = s.core.render(s.arena.allocator()) catch |err| {
last_error = @intFromError(err);
return 0;
};
const count: usize = @as(usize, surface.cols) * surface.rows;
- s.cells.resize(gpa, count) catch |err| {
- last_error = @intFromError(err);
- return 0;
- };
- for (surface.cells, s.cells.items) |cell, *out| {
+ if (count != s.cells.len) {
+ if (count == 0) {
+ if (s.cells.len > 0) gpa.free(s.cells);
+ s.cells = &.{};
+ } else {
+ const resized = if (s.cells.len == 0)
+ gpa.alloc(WebCell, count)
+ else
+ gpa.realloc(s.cells, count);
+ s.cells = resized catch |err| {
+ last_error = @intFromError(err);
+ return 0;
+ };
+ }
+ }
+ s.frame_len = count;
+ s.frame_cols = surface.cols;
+ s.frame_rows = surface.rows;
+ for (surface.cells, s.cells[0..count]) |cell, *out| {
out.* = .{
.text = @splat(0),
.fg = encodeColor(cell.style.fg),
@@ -309,17 +356,17 @@ export fn pardes_frame() u32 {
export fn pardes_frame_ptr() u32 {
const s = &(state orelse return 0);
- return if (s.cells.items.len == 0) 0 else @intCast(@intFromPtr(s.cells.items.ptr));
+ return if (s.frame_len == 0) 0 else @intCast(@intFromPtr(s.cells.ptr));
}
export fn pardes_frame_cols() u32 {
const s = &(state orelse return 0);
- return s.core.screen_w;
+ return s.frame_cols;
}
export fn pardes_frame_rows() u32 {
const s = &(state orelse return 0);
- return s.core.screen_h;
+ return s.frame_rows;
}
export fn pardes_cursor_x() i32 {
@@ -353,7 +400,7 @@ export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 {
// process-capable host does not require another wasm boundary.
export fn pardes_effect_next() u32 {
const s = &(state orelse return 0);
- s.effect_data.clearRetainingCapacity();
+ clearEffect(s);
effect_aux0 = 0;
effect_aux1 = 0;
const effect = s.core.nextEffect() orelse return 0;
@@ -379,11 +426,8 @@ export fn pardes_effect_next() u32 {
},
.save_file => |e| blk: {
effect_aux0 = e.pane;
- if (s.core.panes[e.pane]) |pane| if (pane.file) |file| {
- putU32(s, @intCast(file.path.len));
- appendEffect(s, file.path);
- appendEffect(s, file.content);
- };
+ if (s.core.panes[e.pane]) |pane| if (pane.file) |file|
+ putSaveEffect(s, file.path, file.content);
break :blk 5;
},
.write_dump => blk: {
@@ -402,18 +446,10 @@ export fn pardes_effect_next() u32 {
},
// Process-capable embeddings receive exact command/cwd/input bytes:
// [command_len][cwd_len][count][input lengths...][all payloads...].
- // The ordinary static browser surfaces this as `pardes-io` and stays
- // inert because it has no process authority.
.pipe => |e| blk: {
effect_aux0 = e.id;
const request = s.core.pipeRequest(e.id) orelse break :blk 12;
- putU32(s, @intCast(request.command.len));
- putU32(s, @intCast(request.cwd.len));
- putU32(s, @intCast(request.inputs.len));
- for (request.inputs) |selection| putU32(s, @intCast(selection.bytes.len));
- appendEffect(s, request.command);
- appendEffect(s, request.cwd);
- for (request.inputs) |selection| appendEffect(s, selection.bytes);
+ putPipeEffect(s, request);
break :blk 12;
},
.watch => |e| blk: {
@@ -422,8 +458,6 @@ export fn pardes_effect_next() u32 {
if (e.on) if (s.core.panes[e.pane]) |pane| if (pane.file) |file| putEffect(s, file.path);
break :blk 9;
},
- // Browser hosts cannot create this file, but surface the request like
- // the other host-specific IO effects instead of performing IO here.
.new_file => |e| blk: {
effect_aux0 = e.pane;
effect_aux1 = e.serial;
@@ -435,12 +469,12 @@ export fn pardes_effect_next() u32 {
export fn pardes_effect_ptr() u32 {
const s = &(state orelse return 0);
- return if (s.effect_data.items.len == 0) 0 else @intCast(@intFromPtr(s.effect_data.items.ptr));
+ return if (s.effect_len == 0) 0 else @intCast(@intFromPtr(s.effect_data.ptr));
}
export fn pardes_effect_len() u32 {
const s = &(state orelse return 0);
- return @intCast(s.effect_data.items.len);
+ return @intCast(s.effect_len);
}
export fn pardes_effect_aux0() u32 {
@@ -472,17 +506,62 @@ fn encodeAttrs(style: pardes.CellStyle) u16 {
return attrs;
}
+fn clearEffect(s: *State) void {
+ if (s.effect_data.len > 0) gpa.free(s.effect_data);
+ s.effect_data = &.{};
+ s.effect_len = 0;
+}
+
+fn allocEffect(s: *State, len: usize) ?[]u8 {
+ std.debug.assert(s.effect_data.len == 0 and s.effect_len == 0);
+ if (len == 0) return s.effect_data;
+ s.effect_data = gpa.alloc(u8, len) catch return null;
+ s.effect_len = len;
+ return s.effect_data;
+}
+
fn putEffect(s: *State, bytes: []const u8) void {
- s.effect_data.clearRetainingCapacity();
- appendEffect(s, bytes);
+ const out = allocEffect(s, bytes.len) orelse return;
+ @memcpy(out, bytes);
+}
+
+fn writeEffectU32(out: []u8, at: *usize, value: u32) void {
+ const word: *[4]u8 = @ptrCast(out[at.*..].ptr);
+ std.mem.writeInt(u32, word, value, .little);
+ at.* += 4;
}
-fn appendEffect(s: *State, bytes: []const u8) void {
- s.effect_data.appendSlice(gpa, bytes) catch {};
+fn putSaveEffect(s: *State, path: []const u8, content: []const u8) void {
+ var total = std.math.add(usize, 4, path.len) catch return;
+ total = std.math.add(usize, total, content.len) catch return;
+ const out = allocEffect(s, total) orelse return;
+ var at: usize = 0;
+ writeEffectU32(out, &at, @intCast(path.len));
+ @memcpy(out[at..][0..path.len], path);
+ at += path.len;
+ @memcpy(out[at..][0..content.len], content);
}
-fn putU32(s: *State, value: u32) void {
- var bytes: [4]u8 = undefined;
- std.mem.writeInt(u32, &bytes, value, .little);
- appendEffect(s, &bytes);
+fn putPipeEffect(s: *State, request: anytype) void {
+ var total = std.math.mul(usize, request.inputs.len, 4) catch return;
+ total = std.math.add(usize, total, 12) catch return;
+ total = std.math.add(usize, total, request.command.len) catch return;
+ total = std.math.add(usize, total, request.cwd.len) catch return;
+ for (request.inputs) |selection|
+ total = std.math.add(usize, total, selection.bytes.len) catch return;
+ const out = allocEffect(s, total) orelse return;
+ var at: usize = 0;
+ writeEffectU32(out, &at, @intCast(request.command.len));
+ writeEffectU32(out, &at, @intCast(request.cwd.len));
+ writeEffectU32(out, &at, @intCast(request.inputs.len));
+ for (request.inputs) |selection| writeEffectU32(out, &at, @intCast(selection.bytes.len));
+ @memcpy(out[at..][0..request.command.len], request.command);
+ at += request.command.len;
+ @memcpy(out[at..][0..request.cwd.len], request.cwd);
+ at += request.cwd.len;
+ for (request.inputs) |selection| {
+ @memcpy(out[at..][0..selection.bytes.len], selection.bytes);
+ at += selection.bytes.len;
+ }
+ std.debug.assert(at == out.len);
}
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index eb4c7a2f..c188827e 100644
--- a/test/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -22,6 +22,24 @@ pub fn nowMs() i64 {
_ = libc.clock_gettime(.MONOTONIC, &ts);
return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000);
}
+const raw_capture_max = 64 * 1024 * 1024;
+
+pub const RawCapture = struct {
+ bytes: [raw_capture_max]u8 = undefined,
+ items: []u8 = &.{},
+ full: bool = false,
+
+ fn appendSlice(self: *RawCapture, chunk: []const u8) error{CaptureFull}!void {
+ if (self.full) return error.CaptureFull;
+ if (chunk.len > self.bytes.len - self.items.len) {
+ self.full = true;
+ return error.CaptureFull;
+ }
+ const end = self.items.len + chunk.len;
+ @memcpy(self.bytes[self.items.len..end], chunk);
+ self.items = self.bytes[0..end];
+ }
+};
/// E2E test harness: forks the app in a pty and parses its output with a
/// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString),
@@ -47,8 +65,9 @@ pub const Harness = struct {
trace: bool = false,
/// every raw byte the app has emitted (accumulated in pump). Lets tests assert
/// on control sequences the emulator consumes and never renders (e.g. OSC 52
- /// clipboard writes). gpa-owned; freed in deinit.
- raw: std.ArrayList(u8) = .empty,
+ /// clipboard writes). Capture is bounded explicitly so a runaway child cannot
+ /// consume unbounded test-runner memory.
+ raw: *RawCapture,
pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness {
return initArgs(gpa, exe, rows, cols, null);
@@ -63,14 +82,16 @@ pub const Harness = struct {
/// Like init, but passes extra CLI args (e.g. "-l", "dump.zon").
pub fn initArgv(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, args: []const ?[*:0]const u8) !Harness {
+ const argv = try gpa.allocSentinel(?[*:0]const u8, args.len + 1, null);
+ defer gpa.free(argv);
+ argv[0] = exe;
+ for (args, 0..) |a, i| argv[i + 1] = a;
+
var master: c_int = undefined;
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
- var argv: [8:null]?[*:0]const u8 = @splat(null);
- argv[0] = exe;
- for (args, 0..) |a, i| argv[i + 1] = a;
- _ = execvp(exe, &argv);
+ _ = execvp(exe, argv.ptr);
_exit(127);
}
const term = try gpa.create(ghostty_vt.Terminal);
@@ -84,6 +105,9 @@ pub const Harness = struct {
// vtStream() captures a pointer to `term`; term is heap-allocated so the
// pointer stays valid for the life of the harness.
const stream = term.vtStream();
+ const raw = try gpa.create(RawCapture);
+ errdefer gpa.destroy(raw);
+ raw.* = .{};
return .{
.gpa = gpa,
.master = master,
@@ -92,6 +116,7 @@ pub const Harness = struct {
.stream = stream,
.rows = rows,
.cols = cols,
+ .raw = raw,
};
}
@@ -99,7 +124,7 @@ pub const Harness = struct {
posix.kill(self.pid, posix.SIG.KILL) catch {};
_ = libc.close(self.master);
self.stream.deinit();
- self.raw.deinit(self.gpa);
+ self.gpa.destroy(self.raw);
self.term.deinit(self.gpa);
self.gpa.destroy(self.term);
}
@@ -128,7 +153,7 @@ pub const Harness = struct {
if ((fds[0].revents & posix.POLL.IN) != 0) {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
- self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.recordRaw(buf[0..n]);
self.stream.nextSlice(buf[0..n]);
}
}
@@ -147,7 +172,7 @@ pub const Harness = struct {
var buf: [4096]u8 = undefined;
const n = posix.read(self.master, &buf) catch return false;
if (n == 0) return false;
- self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.recordRaw(buf[0..n]);
self.stream.nextSlice(buf[0..n]);
return true;
}
@@ -206,7 +231,7 @@ pub const Harness = struct {
if ((fds[0].revents & posix.POLL.IN) != 0) {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
- self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.recordRaw(buf[0..n]);
self.stream.nextSlice(buf[0..n]);
}
const text = try self.screenText();
@@ -219,6 +244,11 @@ pub const Harness = struct {
if (self.trace) self.traceScreen("waitFor miss");
return false;
}
+ fn recordRaw(self: *Harness, bytes: []const u8) void {
+ self.raw.appendSlice(bytes) catch |err| switch (err) {
+ error.CaptureFull => {},
+ };
+ }
/// The visual cursor position (viewport coords): { x, y }.
pub fn cursor(self: *Harness) struct { x: u16, y: u16 } {
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
index f6ab5c6f..599390d6 100644
--- a/test/hxdiff.zig
+++ b/test/hxdiff.zig
@@ -90,7 +90,8 @@ pub fn main(init: std.process.Init) !void {
var parity = false;
var goldens_path: ?[]const u8 = null;
var waivers_path: ?[]const u8 = null;
- var case_paths: std.ArrayList([]const u8) = .empty;
+ const case_paths = arena.alloc([]const u8, args.len - 1) catch fatal("oom", .{});
+ var n_case_paths: usize = 0;
var want_waivers = false;
for (args[1..]) |a| {
if (want_waivers) {
@@ -100,17 +101,18 @@ pub fn main(init: std.process.Init) !void {
parity = true;
} else if (std.mem.eql(u8, a, "--waivers")) {
want_waivers = true;
- } else if (parity or case_paths.items.len == 0) {
+ } else if (parity or n_case_paths == 0) {
// diff mode is positional (cases, goldens, waivers); parity mode
// takes any number of case files and nothing else
- case_paths.append(arena, a) catch fatal("oom", .{});
+ case_paths[n_case_paths] = a;
+ n_case_paths += 1;
} else if (goldens_path == null) {
goldens_path = a;
} else if (waivers_path == null) {
waivers_path = a;
} else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
}
- if (case_paths.items.len == 0)
+ if (n_case_paths == 0)
fatal("usage: pardes-hxdiff [--parity] <cases.jsonl>... [goldens.jsonl [waivers.jsonl]]", .{});
// goldens + waivers, keyed by name (diff mode)
@@ -144,11 +146,11 @@ pub fn main(init: std.process.Init) !void {
}
}
- var out: std.ArrayList(u8) = .empty;
+ const stdout = std.Io.File.stdout();
var n_cases: usize = 0;
var n_bad: usize = 0;
var n_waived: usize = 0;
- for (case_paths.items) |path| {
+ for (case_paths[0..n_case_paths]) |path| {
const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
var lines = std.mem.splitScalar(u8, src, '\n');
var lineno: usize = 0;
@@ -179,8 +181,8 @@ pub fn main(init: std.process.Init) !void {
// null sels/primary are DROPPED, not written: that is what
// keeps a one-selection line identical to the old contract
const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{});
- out.appendSlice(arena, jl) catch fatal("oom", .{});
- out.append(arena, '\n') catch fatal("oom", .{});
+ try stdout.writeStreamingAll(init.io, jl);
+ try stdout.writeStreamingAll(init.io, "\n");
continue;
}
const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
@@ -195,10 +197,7 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
}
}
- if (!parity and goldens_path == null) {
- try std.Io.File.stdout().writeStreamingAll(init.io, out.items);
- return;
- }
+ if (!parity and goldens_path == null) return;
std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", n_cases, n_bad, n_waived });
if (n_bad > 0) std.process.exit(1);
}
@@ -235,18 +234,23 @@ const Waiver = struct {
/// field-by-field compare; returns a human-readable report ("" = match).
/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty).
fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 {
- var buf: std.ArrayList(u8) = .empty;
+ var parts: [5][]const u8 = undefined;
+ var n: usize = 0;
if (!std.mem.eql(u8, want.text, got.text)) {
- appendf(arena, &buf, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) });
+ parts[n] = std.fmt.allocPrint(arena, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch "";
+ n += 1;
}
if (!std.mem.eql(u8, want.mode, got.mode)) {
- appendf(arena, &buf, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode });
+ parts[n] = std.fmt.allocPrint(arena, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch "";
+ n += 1;
}
if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) {
- appendf(arena, &buf, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
+ parts[n] = std.fmt.allocPrint(arena, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }) catch "";
+ n += 1;
}
if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) {
- appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
+ parts[n] = std.fmt.allocPrint(arena, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }) catch "";
+ n += 1;
}
// the whole selection: absent on both sides = one range, and the two
// fields above already said everything about it
@@ -258,31 +262,58 @@ fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u
a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true;
};
if (sel_diff) {
- appendf(arena, &buf, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) });
+ parts[n] = std.fmt.allocPrint(arena, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }) catch "";
+ n += 1;
}
- return buf.items;
+ return concatParts(arena, parts[0..n]);
}
/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to
/// one pair when they are the same cell), the primary starred
fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 {
if (sels.len == 0) return "<one>";
- var buf: std.ArrayList(u8) = .empty;
- appendf(arena, &buf, "{d} of [", .{sels.len});
+ var part_count: usize = 2;
for (sels, 0..) |s, i| {
- if (i > 0) appendf(arena, &buf, " ", .{});
- if (i == (primary orelse 0)) appendf(arena, &buf, "*", .{});
- appendf(arena, &buf, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) });
- if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col)
- appendf(arena, &buf, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) });
+ part_count += 1;
+ if (i > 0) part_count += 1;
+ if (i == (primary orelse 0)) part_count += 1;
+ if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) part_count += 1;
}
- appendf(arena, &buf, "]", .{});
- return buf.items;
+ const parts = arena.alloc([]const u8, part_count) catch return "";
+ var n: usize = 0;
+ parts[n] = std.fmt.allocPrint(arena, "{d} of [", .{sels.len}) catch "";
+ n += 1;
+ for (sels, 0..) |s, i| {
+ if (i > 0) {
+ parts[n] = " ";
+ n += 1;
+ }
+ if (i == (primary orelse 0)) {
+ parts[n] = "*";
+ n += 1;
+ }
+ parts[n] = std.fmt.allocPrint(arena, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }) catch "";
+ n += 1;
+ if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) {
+ parts[n] = std.fmt.allocPrint(arena, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }) catch "";
+ n += 1;
+ }
+ }
+ parts[n] = "]";
+ n += 1;
+ return concatParts(arena, parts[0..n]);
}
-fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []const u8, fargs: anytype) void {
- const s = std.fmt.allocPrint(arena, fmt, fargs) catch return;
- buf.appendSlice(arena, s) catch {};
+fn concatParts(arena: std.mem.Allocator, parts: []const []const u8) []const u8 {
+ var len: usize = 0;
+ for (parts) |part| len = std.math.add(usize, len, part.len) catch return "";
+ const out = arena.alloc(u8, len) catch return "";
+ var offset: usize = 0;
+ for (parts) |part| {
+ @memcpy(out[offset..][0..part.len], part);
+ offset += part.len;
+ }
+ return out;
}
fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result {
@@ -297,12 +328,18 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
_ = try core.hxOpenFileContent(case.text);
} else if (std.mem.eql(u8, pane_kind, "tty")) {
// the harness is the pty here: cook \n to \r\n the way ONLCR would
- var cooked: std.ArrayList(u8) = .empty;
+ const cooked_len = std.math.add(usize, case.text.len, std.mem.count(u8, case.text, "\n")) catch return error.OutOfMemory;
+ const cooked = try arena.alloc(u8, cooked_len);
+ var cooked_i: usize = 0;
for (case.text) |ch| {
- if (ch == '\n') try cooked.append(arena, '\r');
- try cooked.append(arena, ch);
+ if (ch == '\n') {
+ cooked[cooked_i] = '\r';
+ cooked_i += 1;
+ }
+ cooked[cooked_i] = ch;
+ cooked_i += 1;
}
- core.update(.{ .output = .{ .pane = 0, .bytes = cooked.items } });
+ core.update(.{ .output = .{ .pane = 0, .bytes = cooked } });
pump(core);
// leave tty mode the way a live session does: Ctrl-<tty_toggle>,
// then park the cursor at (0,0) — the helix harness's initial
@@ -400,12 +437,19 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
// its line list is exactly a file content's — join it and you have the
// same bytes, trailing newline included, with nothing to patch up.
const pl = try core.paneCursorLines(pane);
- var buf: std.ArrayList(u8) = .empty;
+ var text_len: usize = if (pl.lines.len == 0) 0 else pl.lines.len - 1;
+ for (pl.lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory;
+ const joined = try arena.alloc(u8, text_len);
+ var offset: usize = 0;
for (pl.lines, 0..) |ln, i| {
- if (i > 0) try buf.append(arena, '\n');
- try buf.appendSlice(arena, ln);
+ if (i > 0) {
+ joined[offset] = '\n';
+ offset += 1;
+ }
+ @memcpy(joined[offset..][0..ln.len], ln);
+ offset += ln.len;
}
- text = buf.items;
+ text = joined;
}
var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col };
if (pane.vsel.active) {
@@ -423,7 +467,8 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
var sels: ?[]const Sel = null;
var primary: ?usize = null;
if (pane.nsel > 0) {
- var out: std.ArrayList(Sel) = .empty;
+ const out = try arena.alloc(Sel, @as(usize, pane.nsel) + 1);
+ var out_len: usize = 0;
const prim: Sel = .{ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor };
const pr_row = @min(prim.cursor.row, prim.anchor.row);
const pr_col = if (prim.cursor.row == prim.anchor.row) @min(prim.cursor.col, prim.anchor.col) else if (prim.cursor.row < prim.anchor.row) prim.cursor.col else prim.anchor.col;
@@ -431,16 +476,18 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
const s_row = @min(s.row, s.arow);
const s_col = if (s.row == s.arow) @min(s.col, s.acol) else if (s.row < s.arow) s.col else s.acol;
if (primary == null and (pr_row < s_row or (pr_row == s_row and pr_col <= s_col))) {
- primary = out.items.len;
- try out.append(arena, prim);
+ primary = out_len;
+ out[out_len] = prim;
+ out_len += 1;
}
- try out.append(arena, .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } });
+ out[out_len] = .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } };
+ out_len += 1;
}
if (primary == null) {
- primary = out.items.len;
- try out.append(arena, prim);
+ primary = out_len;
+ out[out_len] = prim;
}
- sels = out.items;
+ sels = out;
}
return .{
.name = case.name,
@@ -464,18 +511,30 @@ fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- var chunk: [16384]u8 = undefined;
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed;
+ const size = std.math.cast(usize, end) orelse return error.FileTooLarge;
+ const buf = try arena.alloc(u8, size);
+ var offset: usize = 0;
+ while (offset < buf.len) {
+ const n = libc.read(fd, buf[offset..].ptr, buf.len - offset);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) return buf[0..offset];
+ offset += @intCast(n);
+ }
+ var extra: [1]u8 = undefined;
while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const n = libc.read(fd, &extra, extra.len);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
- if (n == 0) break;
- try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ if (n > 0) return error.FileChanged;
+ return buf;
}
- return buf.items;
}
fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
diff --git a/test/image_harness.zig b/test/image_harness.zig
index d4cb8955..601bce49 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -73,8 +73,27 @@ fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void {
_ = libc.rmdir(base);
}
-fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void {
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args));
+const pdf_fixture_cap = 4096;
+
+const BoundedPdf = struct {
+ bytes: [pdf_fixture_cap]u8 = undefined,
+ len: usize = 0,
+
+ fn appendSlice(out: *BoundedPdf, bytes: []const u8) !void {
+ if (bytes.len > out.bytes.len - out.len) return error.FixturePdfTooLarge;
+ @memcpy(out.bytes[out.len..][0..bytes.len], bytes);
+ out.len += bytes.len;
+ }
+
+ fn written(out: *const BoundedPdf) []const u8 {
+ return out.bytes[0..out.len];
+ }
+};
+
+fn appendFmt(out: *BoundedPdf, comptime fmt: []const u8, args: anytype) !void {
+ const bytes = std.fmt.bufPrint(out.bytes[out.len..], fmt, args) catch
+ return error.FixturePdfTooLarge;
+ out.len += bytes.len;
}
/// A self-contained, valid PDF fixture. The first two pages retain the red /
@@ -83,6 +102,7 @@ fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []
/// headers. Building it here keeps the backend test independent of mutool,
/// fonts, and checked-in binary fixtures while the xref offsets remain exact.
fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
+ _ = arena;
const first_stream =
\\1 0 0 rg
\\0 0 72 48 re
@@ -128,54 +148,63 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
\\ET
;
- var out: std.ArrayList(u8) = .empty;
+ var out: BoundedPdf = .{};
var offsets: [16]usize = @splat(0);
- try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
+ try out.appendSlice("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
- offsets[1] = out.items.len;
- try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n");
- offsets[2] = out.items.len;
- try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n");
- offsets[3] = out.items.len;
- try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n");
- offsets[4] = out.items.len;
- try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream });
- offsets[5] = out.items.len;
- try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n");
- offsets[6] = out.items.len;
- try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream });
- offsets[7] = out.items.len;
- try out.appendSlice(arena, "7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n");
- offsets[8] = out.items.len;
- try appendFmt(arena, &out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream });
- offsets[9] = out.items.len;
- try out.appendSlice(arena, "9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n");
- offsets[10] = out.items.len;
- try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream });
- offsets[11] = out.items.len;
- try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
- offsets[12] = out.items.len;
- try out.appendSlice(arena, "12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n");
- offsets[13] = out.items.len;
- try out.appendSlice(arena, "13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n");
- offsets[14] = out.items.len;
- try out.appendSlice(arena, "14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n");
- offsets[15] = out.items.len;
- try out.appendSlice(arena, "15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n");
+ offsets[1] = out.len;
+ try out.appendSlice("1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n");
+ offsets[2] = out.len;
+ try out.appendSlice("2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n");
+ offsets[3] = out.len;
+ try out.appendSlice("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n");
+ offsets[4] = out.len;
+ try appendFmt(&out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream });
+ offsets[5] = out.len;
+ try out.appendSlice("5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n");
+ offsets[6] = out.len;
+ try appendFmt(&out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream });
+ offsets[7] = out.len;
+ try out.appendSlice("7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n");
+ offsets[8] = out.len;
+ try appendFmt(&out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream });
+ offsets[9] = out.len;
+ try out.appendSlice("9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n");
+ offsets[10] = out.len;
+ try appendFmt(&out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream });
+ offsets[11] = out.len;
+ try out.appendSlice("11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
+ offsets[12] = out.len;
+ try out.appendSlice("12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n");
+ offsets[13] = out.len;
+ try out.appendSlice("13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n");
+ offsets[14] = out.len;
+ try out.appendSlice("14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n");
+ offsets[15] = out.len;
+ try out.appendSlice("15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n");
- const xref = out.items.len;
- try out.appendSlice(arena, "xref\n0 16\n0000000000 65535 f \n");
+ const xref = out.len;
+ try out.appendSlice("xref\n0 16\n0000000000 65535 f \n");
for (offsets[1..]) |offset|
- try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset});
- try appendFmt(arena, &out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
- try eh.writeFile(path, out.items);
+ try appendFmt(&out, "{d:0>10} 00000 n \n", .{offset});
+ try appendFmt(&out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ try eh.writeFile(path, out.written());
}
fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void {
- var out: std.ArrayList(u8) = .empty;
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height }));
- for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb);
- try eh.writeFile(path, out.items);
+ var header_buf: [64]u8 = undefined;
+ const header = std.fmt.bufPrint(&header_buf, "P6\n{d} {d}\n255\n", .{ width, height }) catch
+ return error.FixturePpmHeaderTooLarge;
+ const pixel_count = try std.math.mul(usize, width, height);
+ const pixel_bytes = try std.math.mul(usize, pixel_count, fixture_rgb.len);
+ const out = try arena.alloc(u8, try std.math.add(usize, header.len, pixel_bytes));
+ @memcpy(out[0..header.len], header);
+ var offset = header.len;
+ for (0..pixel_count) |_| {
+ @memcpy(out[offset..][0..fixture_rgb.len], &fixture_rgb);
+ offset += fixture_rgb.len;
+ }
+ try eh.writeFile(path, out);
}
fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
@@ -656,16 +685,18 @@ fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 {
const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0));
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(allocator);
- var buf: [8192]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &buf, buf.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
+ const out = try allocator.alloc(u8, @intCast(end));
+ errdefer allocator.free(out);
+ var filled: usize = 0;
+ while (filled < out.len) {
+ const n = libc.read(fd, out[filled..].ptr, out.len - filled);
if (n < 0) return error.ReadFailed;
- if (n == 0) break;
- try out.appendSlice(allocator, buf[0..@intCast(n)]);
+ if (n == 0) return error.UnexpectedEof;
+ filled += @intCast(n);
}
- return out.toOwnedSlice(allocator);
+ return out;
}
fn ppmPixels(bytes: []const u8) ?[]const u8 {
diff --git a/test/lspbench.zig b/test/lspbench.zig
index 329e5743..964d3c8c 100644
--- a/test/lspbench.zig
+++ b/test/lspbench.zig
@@ -147,8 +147,8 @@ pub fn main(init: std.process.Init) !void {
}
const rss0 = rssKib();
- var results: std.ArrayList(Result) = .empty;
- defer results.deinit(gpa);
+ var results: [anchors.len]Result = undefined;
+ var result_count: usize = 0;
for (anchors) |an| {
const path = try std.fs.path.join(gpa, &.{ root, an.file });
@@ -193,11 +193,12 @@ pub fn main(init: std.process.Init) !void {
lsp.query(gpa, arena.allocator(), req, &out.writer);
break :blk std.mem.indexOf(u8, out.written(), an.expect) != null;
});
- try results.append(gpa, r);
+ results[result_count] = r;
+ result_count += 1;
}
const rss = rssKib() -| rss0;
- if (json) reportJson(results.items, rss) else reportText(results.items, rss);
+ if (json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss);
}
fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 {
@@ -264,19 +265,23 @@ fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- errdefer buf.deinit(gpa);
- var chunk: [16384]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
+ const len: usize = @intCast(end);
+ const buf = try gpa.alloc(u8, len + 1);
+ errdefer gpa.free(buf);
+ var filled: usize = 0;
+ while (filled < len) {
+ const n = libc.read(fd, buf[filled..].ptr, len - filled);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
- if (n == 0) break;
- try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ if (n == 0) return error.UnexpectedEof;
+ filled += @intCast(n);
}
- return buf.toOwnedSliceSentinel(gpa, 0);
+ buf[len] = 0;
+ return buf[0..len :0];
}
/// VmHWM from /proc/self/status — the peak, not the current, so a backend that
diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig
index fb911bee..0f09f72a 100644
--- a/test/pdf_bench.zig
+++ b/test/pdf_bench.zig
@@ -470,6 +470,8 @@ fn mib(bytes: u64) f64 {
return @as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0);
}
+const report_max_bytes = 64 * 1024;
+
fn reportText(
io: std.Io,
config: Config,
@@ -477,10 +479,9 @@ fn reportText(
multi_pages: usize,
results: []const Result,
) void {
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
+ var storage: [report_max_bytes]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
out.print(
- gpa,
"pardes PDF render benchmark (ReleaseFast)\n" ++
"path: {s} ({d} pages), selected page: {d}, multi-page pass: {d} pages\n" ++
"warmup: {d}, sampled reps: {d}; cold = fresh Document (OS cache uncontrolled)\n" ++
@@ -500,8 +501,7 @@ fn reportText(
tint_colors.foreground[2],
},
) catch return;
- out.appendSlice(
- gpa,
+ out.writeAll(
"profile scenario min ms median p90 max ms MPix/s GiB/s pixels/op RGBA MiB checksum\n" ++
"-----------------------------------------------------------------------------------------------------------------------------------\n",
) catch return;
@@ -509,7 +509,6 @@ fn reportText(
const profile = profiles[result.profile];
const scenario = scenarios[result.scenario];
out.print(
- gpa,
"{s:<20} {s:<26} {d:>8.2} {d:>8.2} {d:>8.2} {d:>8.2} {d:>7.1} {d:>6.2} {d:>10} {d:>9.2} {x:0>16}\n",
.{
profile.id,
@@ -526,10 +525,9 @@ fn reportText(
},
) catch return;
}
- out.appendSlice(gpa, "\nrequests:\n") catch return;
+ out.writeAll("\nrequests:\n") catch return;
for (profiles) |profile| {
out.print(
- gpa,
" {s}: dpi={d}, viewport-min={d}x{d}, max-dimension={d} — {s}\n",
.{
profile.id,
@@ -541,7 +539,7 @@ fn reportText(
},
) catch return;
}
- std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+ std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
}
fn reportJson(
@@ -551,12 +549,11 @@ fn reportJson(
multi_pages: usize,
results: []const Result,
) void {
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
+ var storage: [report_max_bytes]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
const path_json = std.json.Stringify.valueAlloc(gpa, config.path, .{}) catch return;
defer gpa.free(path_json);
out.print(
- gpa,
"{{\"benchmark\":\"pardes-pdf-render\",\"build\":\"ReleaseFast\"," ++
"\"path\":{s},\"document_pages\":{d},\"selected_page\":{d}," ++
"\"multi_pages\":{d},\"warmup\":{d},\"reps\":{d}," ++
@@ -579,7 +576,6 @@ fn reportJson(
) catch return;
for (profiles, 0..) |profile, i| {
out.print(
- gpa,
"{s}{{\"id\":\"{s}\",\"dpi\":{d},\"minimum_width\":{d}," ++
"\"minimum_height\":{d},\"max_dimension\":{d}}}",
.{
@@ -592,10 +588,9 @@ fn reportJson(
},
) catch return;
}
- out.appendSlice(gpa, "],\"results\":[") catch return;
+ out.writeAll("],\"results\":[") catch return;
for (results, 0..) |result, i| {
out.print(
- gpa,
"{s}{{\"profile\":\"{s}\",\"scenario\":\"{s}\"," ++
"\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d}," ++
"\"max_ns\":{d},\"sampled_ns\":{d},\"pixels\":{d}," ++
@@ -618,8 +613,8 @@ fn reportJson(
},
) catch return;
}
- out.appendSlice(gpa, "]}\n") catch return;
- std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+ out.writeAll("]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
}
fn fatal(comptime format: []const u8, args: anytype) noreturn {
diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig
index 21327c9f..9d44a3dd 100644
--- a/test/pdf_sections_bench.zig
+++ b/test/pdf_sections_bench.zig
@@ -116,8 +116,8 @@ fn parseCount(flag: []const u8, text: []const u8) usize {
}
fn measureOutline(path: []const u8, config: Config) !Result {
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
// Document open/close is setup: this row isolates MuPDF outline load,
@@ -130,9 +130,9 @@ fn measureOutline(path: []const u8, config: Config) !Result {
const identity = outlineIdentity(outline.entries);
outline.deinit(gpa);
try verifyIdentity("outline-load-flatten-real", &expected, identity, round);
- if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed);
}
- return summarize("outline-load-flatten-real", 1, samples.items, expected.?);
+ return summarize("outline-load-flatten-real", 1, samples, expected.?);
}
fn measureMaterialize(
@@ -140,8 +140,8 @@ fn measureMaterialize(
entries: []const pdf.OutlineEntry,
config: Config,
) !Result {
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
const started = nowNs();
@@ -150,17 +150,17 @@ fn measureMaterialize(
const identity = contentIdentity(content);
gpa.free(content);
try verifyIdentity("sections-first-materialize", &expected, identity, round);
- if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed);
}
- return summarize("sections-first-materialize", 1, samples.items, expected.?);
+ return summarize("sections-first-materialize", 1, samples, expected.?);
}
fn measureCachedReopen(path: []const u8, config: Config) !Result {
const core = try preparedCore(path);
defer core.deinit();
const prepared = preparedReopen(core);
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
var observed: u64 = 0;
@@ -182,9 +182,9 @@ fn measureCachedReopen(path: []const u8, config: Config) !Result {
identity.checksum = mix(identity.checksum, observed);
try verifyIdentity("sections-clean-cached-reopen", &expected, identity, round);
if (round >= config.warmup)
- try samples.append(gpa, @max(1, elapsed / cached_batch));
+ samples[round - config.warmup] = @max(1, elapsed / cached_batch);
}
- return summarize("sections-clean-cached-reopen", cached_batch, samples.items, expected.?);
+ return summarize("sections-clean-cached-reopen", cached_batch, samples, expected.?);
}
/// Cost floor for the anti-elision workload above. This performs the same
@@ -194,8 +194,8 @@ fn measureCachedControl(path: []const u8, config: Config) !Result {
const core = try preparedCore(path);
defer core.deinit();
const prepared = preparedReopen(core);
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
var observed: u64 = 0;
@@ -212,17 +212,17 @@ fn measureCachedControl(path: []const u8, config: Config) !Result {
identity.checksum = mix(identity.checksum, observed);
try verifyIdentity("sections-perturb-observe-control", &expected, identity, round);
if (round >= config.warmup)
- try samples.append(gpa, @max(1, elapsed / cached_batch));
+ samples[round - config.warmup] = @max(1, elapsed / cached_batch);
}
- return summarize("sections-perturb-observe-control", cached_batch, samples.items, expected.?);
+ return summarize("sections-perturb-observe-control", cached_batch, samples, expected.?);
}
fn measureOrdinalStep(path: []const u8, config: Config) !Result {
const core = try preparedCore(path);
defer core.deinit();
const owner = core.panes[0].?;
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
owner.search_row = null;
@@ -244,14 +244,14 @@ fn measureOrdinalStep(path: []const u8, config: Config) !Result {
const identity = stepIdentity(core);
try verifyIdentity("sections-ordinal-destination-step", &expected, identity, round);
if (round >= config.warmup)
- try samples.append(gpa, @max(1, elapsed / step_batch));
+ samples[round - config.warmup] = @max(1, elapsed / step_batch);
}
- return summarize("sections-ordinal-destination-step", step_batch, samples.items, expected.?);
+ return summarize("sections-ordinal-destination-step", step_batch, samples, expected.?);
}
fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result {
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
var expected: ?Identity = null;
for (0..config.warmup + config.reps) |round| {
const started = nowNs();
@@ -260,9 +260,9 @@ fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result
const identity = contentIdentity(content);
gpa.free(content);
try verifyIdentity("rows-near-limit-worst-shape", &expected, identity, round);
- if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed);
}
- return summarize("rows-near-limit-worst-shape", 1, samples.items, expected.?);
+ return summarize("rows-near-limit-worst-shape", 1, samples, expected.?);
}
fn preparedCore(path: []const u8) !*pardes.Pardes {
@@ -495,14 +495,15 @@ fn reportText(config: Config, results: []const Result) void {
std.debug.print("\nBatched rows divide elapsed time by operations/sample: cached={d}, step={d}.\n", .{ cached_batch, step_batch });
}
+const json_report_max_bytes = 16 * 1024;
+
fn reportJson(io: std.Io, config: Config, results: []const Result) void {
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
- out.print(gpa, "{{\"benchmark\":\"pardes-pdf-sections\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[", .{
+ var storage: [json_report_max_bytes]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
+ out.print("{{\"benchmark\":\"pardes-pdf-sections\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[", .{
config.warmup, config.reps,
}) catch return;
for (results, 0..) |result, i| out.print(
- gpa,
"{s}{{\"scenario\":\"{s}\",\"operations_per_sample\":{d},\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"max_ns\":{d},\"items\":{d},\"bytes\":{d},\"checksum\":\"{x:0>16}\"}}",
.{
if (i == 0) "" else ",",
@@ -517,8 +518,8 @@ fn reportJson(io: std.Io, config: Config, results: []const Result) void {
result.identity.checksum,
},
) catch return;
- out.appendSlice(gpa, "]}\n") catch return;
- std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+ out.writeAll("]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
}
fn writeFile(path: []const u8, bytes: []const u8) !void {
diff --git a/test/perf.zig b/test/perf.zig
index 368e6ce1..bf5f0200 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -166,8 +166,8 @@ pub fn main(init: std.process.Init) !void {
/// big a difference has to be before it is real.
fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell {
const warmup = 3;
- var samples: std.ArrayList(u64) = .empty;
- defer samples.deinit(gpa);
+ const samples = try gpa.alloc(u64, reps);
+ defer gpa.free(samples);
if (op == .open) {
// fresh core per sample: opening is a one-shot, and the second Look at
@@ -180,9 +180,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell {
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
- if (n >= warmup) try samples.append(gpa, dt / 1000);
+ if (n >= warmup) samples[n - warmup] = dt / 1000;
}
- return summarize(samples.items);
+ return summarize(samples);
}
const core = try boot();
@@ -217,9 +217,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell {
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
- if (n >= warmup) try samples.append(gpa, dt / 1000);
+ if (n >= warmup) samples[n - warmup] = dt / 1000;
}
- return summarize(samples.items);
+ return summarize(samples);
}
/// Park the cursor and the view at sample `n`'s position, walked across the
@@ -291,22 +291,39 @@ fn summarize(samples: []u64) Cell {
/// lines get their width from a comment tail — the alternative (an enormous
/// string literal) makes the whole file one token and flatters every scan that
/// looks for a newline.
+fn generatedPrefixLen(n: usize) usize {
+ return switch (n % 4) {
+ 0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
+ 1 => std.fmt.count("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
+ 2 => std.fmt.count(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
+ else => std.fmt.count("// comment line {d} — ", .{n}),
+ };
+}
+
+fn writeGeneratedPrefix(out: *std.Io.Writer, n: usize) !void {
+ switch (n % 4) {
+ 0 => try out.print("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
+ 1 => try out.print("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
+ 2 => try out.print(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
+ else => try out.print("// comment line {d} — ", .{n}),
+ }
+}
+
fn generate(fx: Fixture) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
- try out.ensureTotalCapacity(gpa, fx.lines * (fx.cols + 1) + 64);
- var n: usize = 0;
- while (n < fx.lines) : (n += 1) {
- const start = out.items.len;
- switch (n % 4) {
- 0 => try out.print(gpa, "const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
- 1 => try out.print(gpa, "pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
- 2 => try out.print(gpa, " const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
- else => try out.print(gpa, "// comment line {d} — ", .{n}),
- }
- while (out.items.len - start < fx.cols) try out.append(gpa, 'x');
- try out.append(gpa, '\n');
+ var byte_len: usize = 0;
+ for (0..fx.lines) |n| byte_len += @max(generatedPrefixLen(n), fx.cols) + 1;
+ const text = try gpa.alloc(u8, byte_len);
+ errdefer gpa.free(text);
+ var out: std.Io.Writer = .fixed(text);
+ for (0..fx.lines) |n| {
+ const start = out.end;
+ try writeGeneratedPrefix(&out, n);
+ const prefix_len = out.end - start;
+ if (prefix_len < fx.cols) try out.splatByteAll('x', fx.cols - prefix_len);
+ try out.writeByte('\n');
}
- return out.toOwnedSlice(gpa);
+ std.debug.assert(out.end == text.len);
+ return text;
}
fn writeFile(path: []const u8, text: []const u8) !void {
@@ -387,28 +404,30 @@ fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes:
/// Real stdout, not std.debug.print's stderr: this is the form `--base` reads
/// back, and `zig build perf -- --json > runs/old.json` writing an empty file
/// would make the next comparison silently print no ratios at all.
+const json_report_max_bytes = 32 * 1024;
+
fn reportJson(io: std.Io, cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize) void {
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(gpa);
- out.print(gpa, "{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return;
+ var storage: [json_report_max_bytes]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
+ out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return;
for (fixtures, 0..) |fx, fi| {
- out.print(gpa, "{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{
+ out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{
if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi],
}) catch return;
}
- out.appendSlice(gpa, "],\"cells\":[") catch return;
+ out.writeAll("],\"cells\":[") catch return;
var first = true;
for (std.enums.values(Op), 0..) |op, oi| {
for (fixtures, 0..) |fx, fi| {
const c = cells[oi][fi];
- out.print(gpa, "{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{
+ out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{
if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us,
}) catch return;
first = false;
}
}
- out.appendSlice(gpa, "]}\n") catch return;
- std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+ out.writeAll("]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {};
}
const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 };
@@ -420,6 +439,7 @@ const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 };
/// and did not get is worse than no comparison.
fn readBase(path: []const u8) ?Base {
const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path});
+ defer gpa.free(src);
var b: Base = .{ .med = @splat(@splat(0)) };
const parsed = std.json.parseFromSlice(struct {
cells: []const struct {
@@ -446,18 +466,22 @@ fn readFileAlloc(path: []const u8) ![]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- var chunk: [16384]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed;
+ const size = std.math.cast(usize, end) orelse return error.FileTooLarge;
+ const buf = try gpa.alloc(u8, size);
+ errdefer gpa.free(buf);
+ var offset: usize = 0;
+ while (offset < buf.len) {
+ const n = libc.read(fd, buf.ptr + offset, buf.len - offset);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
- if (n == 0) break;
- try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ if (n == 0) return error.UnexpectedEndOfFile;
+ offset += @intCast(n);
}
- return buf.items;
+ return buf;
}
fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 36a5b20b..c3611291 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -91,7 +91,7 @@ pub fn main(init: std.process.Init) !void {
// pass costs more than the extra parallelism saves.
const cpus = std.Thread.getCpuCount() catch 4;
var jobs: usize = cpus + cpus / 4;
- var scripts: std.ArrayList([]const u8) = .empty;
+ var script_count: usize = 0;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
@@ -102,28 +102,42 @@ pub fn main(init: std.process.Init) !void {
} else if (exe == null) {
exe = try absPath(arena, orig_cwd, a);
} else {
- try scripts.append(arena, try absPath(arena, orig_cwd, a));
+ script_count += 1;
}
}
+ var scripts = try arena.alloc([]const u8, script_count);
+ var script_i: usize = 0;
+ var saw_exe = false;
+ for (args[1..]) |a| {
+ if (std.mem.eql(u8, a, "--update") or
+ std.mem.eql(u8, a, "--trace-stable") or
+ std.mem.startsWith(u8, a, "--jobs=")) continue;
+ if (!saw_exe) {
+ saw_exe = true;
+ continue;
+ }
+ scripts[script_i] = try absPath(arena, orig_cwd, a);
+ script_i += 1;
+ }
if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update] [--jobs=N]", .{});
const exe_z = try arena.dupeZ(u8, exe.?);
- if (scripts.items.len == 0) {
- try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"), &scripts);
- std.mem.sort([]const u8, scripts.items, {}, struct {
+ if (scripts.len == 0) {
+ scripts = try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"));
+ std.mem.sort([]const u8, scripts, {}, struct {
fn lt(_: void, x: []const u8, y: []const u8) bool {
return std.mem.lessThan(u8, x, y);
}
}.lt);
}
- if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+ if (scripts.len == 0) fatal("no .snap scripts found", .{});
try mkdir(arena, SNAP_BASE, true);
// one run at a time (this is the flock that used to be typed by hand)
_ = lockOrWait(SNAP_BASE ++ "/.run.lock", "another pardes-snap run");
- const codes = try arena.alloc(u8, scripts.items.len);
- try runBatch(arena, exe_z, toBatch(arena, scripts.items), update, jobs, codes);
+ const codes = try arena.alloc(u8, scripts.len);
+ try runBatch(arena, exe_z, toBatch(arena, scripts), update, jobs, codes);
// A failure in the parallel pass is more often contention than regression:
// the app can lose a race with a kworker while 20 siblings fight for the
@@ -135,11 +149,21 @@ pub fn main(init: std.process.Init) !void {
if (jobs > 1) {
var round: usize = 0;
while (round < 2) : (round += 1) {
- var retry: std.ArrayList(usize) = .empty;
- for (codes, 0..) |c, i| if (c != 0) try retry.append(arena, i);
- if (retry.items.len == 0 or retry.items.len > 10) break;
- const paths = try arena.alloc(Idx, retry.items.len);
- for (retry.items, paths, 0..) |i, *p, n| p.* = .{ .path = scripts.items[i], .code_idx = n };
+ var retry_count: usize = 0;
+ for (codes) |c| {
+ if (c != 0) retry_count += 1;
+ }
+ if (retry_count == 0 or retry_count > 10) break;
+ const retry = try arena.alloc(usize, retry_count);
+ var retry_i: usize = 0;
+ for (codes, 0..) |c, i| {
+ if (c != 0) {
+ retry[retry_i] = i;
+ retry_i += 1;
+ }
+ }
+ const paths = try arena.alloc(Idx, retry.len);
+ for (retry, paths, 0..) |i, *p, n| p.* = .{ .path = scripts[i], .code_idx = n };
std.debug.print("re-running {d} failed script(s) serially{s}:", .{
paths.len,
if (round == 0) "" else " with wider settle probes",
@@ -149,12 +173,12 @@ pub fn main(init: std.process.Init) !void {
careful = round > 0;
const recodes = try arena.alloc(u8, paths.len);
try runBatch(arena, exe_z, paths, update, 1, recodes);
- for (retry.items, recodes) |i, c| codes[i] = c;
+ for (retry, recodes) |i, c| codes[i] = c;
}
}
var failed: usize = 0;
- for (scripts.items, codes) |script_path, code| {
+ for (scripts, codes) |script_path, code| {
const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch "";
std.debug.print("{s}", .{report});
if (code != 0) {
@@ -164,10 +188,10 @@ pub fn main(init: std.process.Init) !void {
}
}
if (failed > 0) {
- std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.items.len });
+ std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.len });
std.process.exit(1);
}
- std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
+ std.debug.print("all {d} snapshot scripts ok\n", .{scripts.len});
}
/// Run `scripts` `jobs` at a time, one forked child each, filling `codes` with
@@ -198,24 +222,26 @@ fn runBatch(
codes: []u8,
) !void {
const Job = struct { pid: libc.pid_t, idx: usize };
- var running: std.ArrayList(Job) = .empty;
- defer running.deinit(arena);
+ const running = try arena.alloc(Job, @min(jobs, scripts.len));
+ var running_len: usize = 0;
var next: usize = 0;
- while (next < scripts.len or running.items.len > 0) {
- while (next < scripts.len and running.items.len < jobs) : (next += 1) {
+ while (next < scripts.len or running_len > 0) {
+ while (next < scripts.len and running_len < running.len) : (next += 1) {
const idx = next;
const pid = libc.fork();
if (pid == 0) std.process.exit(runOne(arena, exe_z, scripts[idx].path, update));
if (pid < 0) fatal("fork failed", .{});
- try running.append(arena, .{ .pid = pid, .idx = scripts[idx].code_idx });
+ running[running_len] = .{ .pid = pid, .idx = scripts[idx].code_idx };
+ running_len += 1;
}
var status: c_int = 0;
const pid = libc.waitpid(-1, &status, 0);
- for (running.items, 0..) |j, i| {
+ for (running[0..running_len], 0..) |j, i| {
if (j.pid != pid) continue;
// a killed child (crash in the harness itself) counts as a failure
codes[j.idx] = if (status == 0) 0 else 1;
- _ = running.swapRemove(i);
+ running_len -= 1;
+ running[i] = running[running_len];
break;
}
}
@@ -337,7 +363,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
_ = setenv("PARDES_DUMP", try arena.dupeZ(u8, dump_path), 1);
if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
- var out: std.ArrayList(u8) = .empty;
+ const chunks = try arena.alloc([]const u8, countCaptures(src));
+ var chunk_len: usize = 0;
var h: ?eh.Harness = null;
defer if (h) |*hp| hp.deinit();
@@ -368,9 +395,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// one) — a file that is long AND wide, which is what a horizontal
// scroll test needs and `file` can only give as one huge script line
const tail = tok.rest();
- var buf: std.ArrayList(u8) = .empty;
- for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}{s}\n", .{ i, tail }));
- try eh.writeFile(try arena.dupeZ(u8, name), buf.items);
+ const buf = try numberedLines(arena, n, tail);
+ try eh.writeFile(try arena.dupeZ(u8, name), buf);
} else if (std.mem.eql(u8, cmd, "ppmdiag")) {
// The diagonal-split test PPM from the old e2e (white over blue),
// grown to 512x512 with the white half filled by a fixed LCG
@@ -381,19 +407,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// the art, which a 64x64 image cannot show (its cells average
// down to flat color and a block always wins).
const name = tok.next() orelse return error.BadScript;
- var ppm: std.ArrayList(u8) = .empty;
- try ppm.appendSlice(arena, "P6\n512 512\n255\n");
- var seed: u64 = 12345;
- var py: usize = 0;
- while (py < 512) : (py += 1) {
- var px: usize = 0;
- while (px < 512) : (px += 1) {
- seed = seed *% 1103515245 +% 12345;
- const v: u8 = @truncate(seed >> 16);
- try ppm.appendSlice(arena, if (px + py < 512) &[_]u8{ v, v, v } else &[_]u8{ 0, 0, 255 });
- }
- }
- try eh.writeFile(try arena.dupeZ(u8, name), ppm.items);
+ const ppm = try ppmDiag(arena);
+ try eh.writeFile(try arena.dupeZ(u8, name), ppm);
} else if (std.mem.eql(u8, cmd, "dirmk")) {
try mkdir(arena, tok.next() orelse return error.BadScript, false);
} else if (std.mem.eql(u8, cmd, "run")) {
@@ -418,22 +433,10 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
} else if (std.mem.eql(u8, cmd, "snapfile")) {
const label = tok.next() orelse return error.BadScript;
const contents = try readFileAlloc(arena, tok.rest());
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== file {s}\n", .{label}));
- // elide base64 payloads: they encode raw pty byte HISTORY, whose
+ // Elide base64 payloads: they encode raw pty byte HISTORY, whose
// micro-timing (prompt redraw cycles) legitimately differs between
// implementations; the cleaned text fields are the contract.
- var flines = std.mem.splitScalar(u8, contents, '\n');
- while (flines.next()) |fl| {
- if (std.mem.indexOf(u8, fl, "_b64 = \"") != null) {
- const eq = std.mem.indexOf(u8, fl, "=").?;
- try out.appendSlice(arena, fl[0 .. eq + 1]);
- try out.appendSlice(arena, " <elided>\n");
- } else {
- try out.appendSlice(arena, fl);
- try out.append(arena, '\n');
- }
- }
- try out.appendSlice(arena, "== end file\n");
+ try appendChunk(chunks, &chunk_len, try snapFile(arena, label, contents));
} else if (std.mem.eql(u8, cmd, "wait")) {
const ms = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
try live(&h).expectWaitFor(tok.rest(), ms, "snapshot wait");
@@ -471,15 +474,119 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
try live(&h).resize(rows, cols);
} else if (std.mem.eql(u8, cmd, "snap")) {
- try snapText(arena, &out, live(&h), tok.rest());
+ try appendChunk(chunks, &chunk_len, try snapText(arena, live(&h), tok.rest()));
} else if (std.mem.eql(u8, cmd, "snapstyle")) {
- try snapStyle(arena, &out, live(&h), tok.rest());
+ try appendChunk(chunks, &chunk_len, try snapStyle(arena, live(&h), tok.rest()));
} else {
std.debug.print("unknown command {s}\n", .{cmd});
return error.BadScript;
}
}
- return out.items;
+ return concatChunks(arena, chunks[0..chunk_len]);
+}
+
+fn countCaptures(src: []const u8) usize {
+ var count: usize = 0;
+ var lines = std.mem.splitScalar(u8, src, '\n');
+ while (lines.next()) |raw_line| {
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (line.len == 0 or line[0] == '#') continue;
+ var tok = std.mem.tokenizeScalar(u8, line, ' ');
+ const cmd = tok.next().?;
+ if (std.mem.eql(u8, cmd, "snap") or
+ std.mem.eql(u8, cmd, "snapstyle") or
+ std.mem.eql(u8, cmd, "snapfile")) count += 1;
+ }
+ return count;
+}
+
+fn appendChunk(chunks: [][]const u8, len: *usize, chunk: []const u8) !void {
+ if (len.* == chunks.len) return error.BadScript;
+ chunks[len.*] = chunk;
+ len.* += 1;
+}
+
+fn concatChunks(arena: std.mem.Allocator, chunks: []const []const u8) ![]u8 {
+ var total: usize = 0;
+ for (chunks) |chunk| total += chunk.len;
+ const out = try arena.alloc(u8, total);
+ var pos: usize = 0;
+ for (chunks) |chunk| copyBytes(out, &pos, chunk);
+ return out;
+}
+
+fn copyBytes(out: []u8, pos: *usize, bytes: []const u8) void {
+ @memcpy(out[pos.*..][0..bytes.len], bytes);
+ pos.* += bytes.len;
+}
+
+fn decimalDigits(value: usize) usize {
+ var v = value;
+ var digits: usize = 1;
+ while (v >= 10) : (digits += 1) v /= 10;
+ return digits;
+}
+
+fn numberedLines(arena: std.mem.Allocator, n: usize, tail: []const u8) ![]u8 {
+ var total: usize = 0;
+ for (1..n + 1) |i| total += "line ".len + decimalDigits(i) + tail.len + 1;
+ const out = try arena.alloc(u8, total);
+ var pos: usize = 0;
+ for (1..n + 1) |i| {
+ const line = try std.fmt.bufPrint(out[pos..], "line {d}{s}\n", .{ i, tail });
+ pos += line.len;
+ }
+ return out;
+}
+
+fn ppmDiag(arena: std.mem.Allocator) ![]u8 {
+ const header = "P6\n512 512\n255\n";
+ const out = try arena.alloc(u8, header.len + 512 * 512 * 3);
+ @memcpy(out[0..header.len], header);
+ var pos = header.len;
+ var seed: u64 = 12345;
+ var py: usize = 0;
+ while (py < 512) : (py += 1) {
+ var px: usize = 0;
+ while (px < 512) : (px += 1) {
+ seed = seed *% 1103515245 +% 12345;
+ const v: u8 = @truncate(seed >> 16);
+ const pixel: [3]u8 = if (px + py < 512) .{ v, v, v } else .{ 0, 0, 255 };
+ @memcpy(out[pos..][0..pixel.len], &pixel);
+ pos += pixel.len;
+ }
+ }
+ return out;
+}
+
+fn snapFile(arena: std.mem.Allocator, label: []const u8, contents: []const u8) ![]u8 {
+ const header = try std.fmt.allocPrint(arena, "== file {s}\n", .{label});
+ var total = header.len + "== end file\n".len;
+ var lines = std.mem.splitScalar(u8, contents, '\n');
+ while (lines.next()) |line| {
+ if (std.mem.indexOf(u8, line, "_b64 = \"") != null) {
+ const eq = std.mem.indexOfScalar(u8, line, '=').?;
+ total += eq + 1 + " <elided>\n".len;
+ } else {
+ total += line.len + 1;
+ }
+ }
+ const out = try arena.alloc(u8, total);
+ var pos: usize = 0;
+ copyBytes(out, &pos, header);
+ lines = std.mem.splitScalar(u8, contents, '\n');
+ while (lines.next()) |line| {
+ if (std.mem.indexOf(u8, line, "_b64 = \"") != null) {
+ const eq = std.mem.indexOfScalar(u8, line, '=').?;
+ copyBytes(out, &pos, line[0 .. eq + 1]);
+ copyBytes(out, &pos, " <elided>\n");
+ } else {
+ copyBytes(out, &pos, line);
+ copyBytes(out, &pos, "\n");
+ }
+ }
+ copyBytes(out, &pos, "== end file\n");
+ return out;
}
fn live(h: *?eh.Harness) *eh.Harness {
@@ -649,48 +756,78 @@ fn stateKey(h: *eh.Harness) ![]u8 {
return std.fmt.allocPrint(h.gpa, "{d},{d},{x}\n{s}", .{ c.x, c.y, styles.final(), text });
}
-fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+fn snapText(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8) ![]u8 {
const text = try h.screenText();
defer h.gpa.free(text);
const c = h.cursor();
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y }));
+ const header = try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y });
+ var total = header.len;
var it = std.mem.splitScalar(u8, text, '\n');
var row: usize = 0;
while (row < h.rows) : (row += 1) {
const line = it.next() orelse "";
- try out.append(arena, '|');
- try out.appendSlice(arena, std.mem.trimEnd(u8, line, " "));
- try out.append(arena, '\n');
+ total += 1 + std.mem.trimEnd(u8, line, " ").len + 1;
}
+ const out = try arena.alloc(u8, total);
+ var pos: usize = 0;
+ copyBytes(out, &pos, header);
+ it = std.mem.splitScalar(u8, text, '\n');
+ row = 0;
+ while (row < h.rows) : (row += 1) {
+ const line = it.next() orelse "";
+ copyBytes(out, &pos, "|");
+ copyBytes(out, &pos, std.mem.trimEnd(u8, line, " "));
+ copyBytes(out, &pos, "\n");
+ }
+ return out;
}
/// Per-row style runs from the harness emulator: `|y: x0-x1 fg,bg,attrs ...`.
/// fg/bg: d (default) | pN (palette) | #rrggbb. attrs: b f i k r v x + u U ~ . -
/// for underline single/double/curly/dotted/dashed.
-fn snapStyle(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s}\n", .{label}));
+fn snapStyle(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8) ![]u8 {
+ const header = try std.fmt.allocPrint(arena, "== style {s}\n", .{label});
+ const rows_len = styleRows(h, null);
+ const out = try arena.alloc(u8, header.len + rows_len);
+ var pos: usize = 0;
+ copyBytes(out, &pos, header);
+ _ = styleRows(h, out[pos..]);
+ return out;
+}
+
+fn styleRows(h: *eh.Harness, dst: ?[]u8) usize {
+ var pos: usize = 0;
var y: u16 = 0;
while (y < h.rows) : (y += 1) {
- try out.appendSlice(arena, try std.fmt.allocPrint(arena, "|{d}:", .{y}));
- var run_buf: [64]u8 = undefined;
+ var fmt_buf: [64]u8 = undefined;
+ emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, "|{d}:", .{y}) catch unreachable);
var key_buf: [64]u8 = undefined;
- var run_key: []const u8 = "";
+ var run_key: [64]u8 = undefined;
+ var run_len: usize = 0;
var run_start: u16 = 0;
var x: u16 = 0;
while (x <= h.cols) : (x += 1) {
const key: []const u8 = if (x == h.cols) "\x00end" else cellKey(h, x, y, &key_buf);
if (x == 0) {
- run_key = try arena.dupe(u8, key);
+ @memcpy(run_key[0..key.len], key);
+ run_len = key.len;
continue;
}
- if (std.mem.eql(u8, key, run_key)) continue;
- try out.appendSlice(arena, try std.fmt.bufPrint(&run_buf, " {d}-{d} ", .{ run_start, x - 1 }));
- try out.appendSlice(arena, run_key);
- run_key = try arena.dupe(u8, key);
+ if (std.mem.eql(u8, key, run_key[0..run_len])) continue;
+ emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, " {d}-{d} ", .{ run_start, x - 1 }) catch unreachable);
+ emitBytes(dst, &pos, run_key[0..run_len]);
+ @memcpy(run_key[0..key.len], key);
+ run_len = key.len;
run_start = x;
}
- try out.append(arena, '\n');
+ emitBytes(dst, &pos, "\n");
}
+ return pos;
+}
+
+fn emitBytes(dst: ?[]u8, pos: *usize, bytes: []const u8) void {
+ if (dst) |out| @memcpy(out[pos.*..][0..bytes.len], bytes);
+ pos.* += bytes.len;
}
fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 {
@@ -810,29 +947,51 @@ fn buttonCode(name: []const u8) ?u16 {
}
fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 {
- var buf: std.ArrayList(u8) = .empty;
+ var len: usize = 0;
var i: usize = 0;
while (i < s.len) : (i += 1) {
if (s[i] != '\\' or i + 1 >= s.len) {
- try buf.append(arena, s[i]);
+ len += 1;
continue;
}
i += 1;
switch (s[i]) {
- 'e' => try buf.append(arena, 0x1b),
- 'r' => try buf.append(arena, '\r'),
- 'n' => try buf.append(arena, '\n'),
- 't' => try buf.append(arena, '\t'),
- '\\' => try buf.append(arena, '\\'),
+ 'e', 'r', 'n', 't', '\\' => len += 1,
'x' => {
if (i + 2 >= s.len) return error.BadEscape;
- try buf.append(arena, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
+ _ = try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16);
+ len += 1;
i += 2;
},
else => return error.BadEscape,
}
}
- return buf.items;
+ const out = try arena.alloc(u8, len);
+ var pos: usize = 0;
+ i = 0;
+ while (i < s.len) : (i += 1) {
+ if (s[i] != '\\' or i + 1 >= s.len) {
+ out[pos] = s[i];
+ pos += 1;
+ continue;
+ }
+ i += 1;
+ out[pos] = switch (s[i]) {
+ 'e' => 0x1b,
+ 'r' => '\r',
+ 'n' => '\n',
+ 't' => '\t',
+ '\\' => '\\',
+ 'x' => value: {
+ const value = try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16);
+ i += 2;
+ break :value value;
+ },
+ else => unreachable,
+ };
+ pos += 1;
+ }
+ return out;
}
fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 {
@@ -840,15 +999,33 @@ fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8
return try std.fmt.allocPrint(arena, "{s}/{s}", .{ cwd, p });
}
-fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8, scripts: *std.ArrayList([]const u8)) !void {
+fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8) ![][]const u8 {
const dir_z = try arena.dupeZ(u8, dir_path);
+ const count = countSnapEntries(dir_z, dir_path);
+ const scripts = try arena.alloc([]const u8, count);
+ const dir = libc.opendir(dir_z) orelse fatal("no snapshots/ dir at {s}", .{dir_path});
+ defer _ = libc.closedir(dir);
+ var i: usize = 0;
+ while (libc.readdir(dir)) |d| {
+ const name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0);
+ if (!std.mem.endsWith(u8, name, ".snap")) continue;
+ if (i == scripts.len) return error.DirectoryChanged;
+ scripts[i] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name });
+ i += 1;
+ }
+ if (i != scripts.len) return error.DirectoryChanged;
+ return scripts;
+}
+
+fn countSnapEntries(dir_z: [:0]const u8, dir_path: []const u8) usize {
const dir = libc.opendir(dir_z) orelse fatal("no snapshots/ dir at {s}", .{dir_path});
defer _ = libc.closedir(dir);
+ var count: usize = 0;
while (libc.readdir(dir)) |d| {
const name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0);
- if (std.mem.endsWith(u8, name, ".snap"))
- try scripts.append(arena, try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }));
+ if (std.mem.endsWith(u8, name, ".snap")) count += 1;
}
+ return count;
}
fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
@@ -856,18 +1033,27 @@ fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- var chunk: [16384]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
+ const buf = try arena.alloc(u8, @intCast(end));
+ var pos: usize = 0;
+ while (pos < buf.len) {
+ const n = libc.read(fd, buf[pos..].ptr, buf.len - pos);
if (n < 0) {
if (libc.errno(n) == .INTR) continue;
return error.ReadFailed;
}
- if (n == 0) break;
- try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ if (n == 0) return error.ReadFailed;
+ pos += @intCast(n);
+ }
+ var extra: [1]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &extra, extra.len);
+ if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n != 0) return error.ReadFailed;
+ break;
}
- return buf.items;
+ return buf;
}
fn mkdir(arena: std.mem.Allocator, path: []const u8, exist_ok: bool) !void {
diff --git a/tools/embed_zig_sources.zig b/tools/embed_zig_sources.zig
index a832798c..61427415 100644
--- a/tools/embed_zig_sources.zig
+++ b/tools/embed_zig_sources.zig
@@ -13,15 +13,12 @@ pub fn main(init: std.process.Init) !void {
const root = args[1];
const output_path = args[2];
- var paths: std.ArrayList([]const u8) = .empty;
+ const paths = (try gitTrackedZigFiles(gpa, io, root)) orelse try walkedZigFiles(gpa, io, root);
defer {
- for (paths.items) |path| gpa.free(path);
- paths.deinit(gpa);
+ for (paths) |path| gpa.free(path);
+ gpa.free(paths);
}
-
- if (!try gitTrackedZigFiles(gpa, io, root, &paths))
- try walkedZigFiles(gpa, io, root, &paths);
- std.mem.sort([]const u8, paths.items, {}, lessThanPath);
+ std.mem.sort([]const u8, paths, {}, lessThanPath);
var generated: std.Io.Writer.Allocating = .init(gpa);
defer generated.deinit();
@@ -32,7 +29,7 @@ pub fn main(init: std.process.Init) !void {
\\
);
- for (paths.items) |path| {
+ for (paths) |path| {
const full_path = try std.Io.Dir.path.join(gpa, &.{ root, path });
defer gpa.free(full_path);
const contents = try std.Io.Dir.cwd().readFileAlloc(io, full_path, gpa, .limited(64 * 1024 * 1024));
@@ -52,40 +49,75 @@ pub fn main(init: std.process.Init) !void {
try output.writeStreamingAll(io, generated.written());
}
-/// Returns true only when Git ran successfully. The working-tree contents are
-/// read later; Git supplies the tracked path filter, not historical blobs.
+/// Returns null only when Git cannot supply the tracked-file list. Working-tree
+/// contents are read later; Git supplies the path filter, not historical blobs.
fn gitTrackedZigFiles(
gpa: std.mem.Allocator,
io: std.Io,
root: []const u8,
- paths: *std.ArrayList([]const u8),
-) !bool {
+) !?[][]const u8 {
const result = std.process.run(gpa, io, .{
.argv = &.{ "git", "-C", root, "ls-files", "-z", "--", "*.zig" },
.stdout_limit = .limited(16 * 1024 * 1024),
.stderr_limit = .limited(1024 * 1024),
- }) catch return false;
+ }) catch return null;
defer gpa.free(result.stdout);
defer gpa.free(result.stderr);
switch (result.term) {
- .exited => |code| if (code != 0) return false,
- else => return false,
+ .exited => |code| if (code != 0) return null,
+ else => return null,
+ }
+
+ var count: usize = 0;
+ var count_it = std.mem.splitScalar(u8, result.stdout, 0);
+ while (count_it.next()) |raw_path| {
+ if (raw_path.len == 0 or !std.mem.endsWith(u8, raw_path, ".zig")) continue;
+ count += 1;
}
+ const paths = try gpa.alloc([]const u8, count);
+ errdefer gpa.free(paths);
+ var index: usize = 0;
+ errdefer for (paths[0..index]) |path| gpa.free(path);
var it = std.mem.splitScalar(u8, result.stdout, 0);
while (it.next()) |raw_path| {
if (raw_path.len == 0 or !std.mem.endsWith(u8, raw_path, ".zig")) continue;
- try paths.append(gpa, try normalizedPath(gpa, raw_path));
+ paths[index] = try normalizedPath(gpa, raw_path);
+ index += 1;
}
- return true;
+ std.debug.assert(index == paths.len);
+ return paths;
}
fn walkedZigFiles(
gpa: std.mem.Allocator,
io: std.Io,
root: []const u8,
- paths: *std.ArrayList([]const u8),
-) !void {
+) ![][]const u8 {
+ var count: usize = 0;
+ {
+ var dir = if (std.Io.Dir.path.isAbsolute(root))
+ try std.Io.Dir.openDirAbsolute(io, root, .{ .iterate = true })
+ else
+ try std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true });
+ defer dir.close(io);
+ var walker = try dir.walk(gpa);
+ defer walker.deinit();
+
+ while (try walker.next(io)) |entry| {
+ if (entry.kind == .directory and ignoredFallbackDir(entry.path)) {
+ walker.leave(io);
+ continue;
+ }
+ if ((entry.kind != .file and entry.kind != .sym_link) or !std.mem.endsWith(u8, entry.path, ".zig")) continue;
+ count += 1;
+ }
+ }
+
+ const paths = try gpa.alloc([]const u8, count);
+ errdefer gpa.free(paths);
+ var index: usize = 0;
+ errdefer for (paths[0..index]) |path| gpa.free(path);
var dir = if (std.Io.Dir.path.isAbsolute(root))
try std.Io.Dir.openDirAbsolute(io, root, .{ .iterate = true })
else
@@ -100,8 +132,11 @@ fn walkedZigFiles(
continue;
}
if ((entry.kind != .file and entry.kind != .sym_link) or !std.mem.endsWith(u8, entry.path, ".zig")) continue;
- try paths.append(gpa, try normalizedPath(gpa, entry.path));
+ paths[index] = try normalizedPath(gpa, entry.path);
+ index += 1;
}
+ std.debug.assert(index == paths.len);
+ return paths;
}
fn ignoredFallbackDir(path: []const u8) bool {
diff --git a/tools/gen_themes.zig b/tools/gen_themes.zig
index 23bcad75..3add2040 100644
--- a/tools/gen_themes.zig
+++ b/tools/gen_themes.zig
@@ -78,32 +78,54 @@ pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(arena);
if (args.len < 3) std.process.fatal("usage: gen-themes <out-dir> <theme-source>...", .{});
- var themes: std.ArrayList(Theme) = .empty;
+ const sources = args[2..];
+ const texts = try arena.alloc([]const u8, sources.len);
+ var helix_count: usize = 0;
+ var theme_count: usize = 0;
+ for (sources, texts) |src, *text| {
+ text.* = try std.Io.Dir.cwd().readFileAlloc(io, src, arena, .limited(8 << 20));
+ if (std.mem.endsWith(u8, src, ".toml")) {
+ helix_count += 1;
+ theme_count += 1;
+ } else if (std.mem.endsWith(u8, src, ".json")) {
+ theme_count += try zedThemeCount(gpa, src, text.*);
+ } else {
+ std.process.fatal("{s}: not a helix .toml or a zed .json", .{src});
+ }
+ }
+
+ const themes = try arena.alloc(Theme, theme_count);
+ const stems = try arena.alloc([]const u8, helix_count);
// TWO passes over the helix half, because `inherits = "other"` names a
// SIBLING SOURCE by stem: nothing can be mapped until every file is read,
// and the parent may be later in argv (or inherit something itself).
var tomls: std.StringHashMapUnmanaged(Toml) = .empty;
- var stems: std.ArrayList([]const u8) = .empty;
- for (args[2..]) |src| {
- const text = try std.Io.Dir.cwd().readFileAlloc(io, src, arena, .limited(8 << 20));
+ var theme_index: usize = 0;
+ var stem_index: usize = 0;
+ for (sources, texts) |src, text| {
if (std.mem.endsWith(u8, src, ".toml")) {
const stem = std.fs.path.stem(src);
try tomls.put(arena, stem, parseToml(arena, src, text));
- try stems.append(arena, stem);
- } else if (std.mem.endsWith(u8, src, ".json"))
- try readZed(arena, gpa, src, text, &themes)
- else
- std.process.fatal("{s}: not a helix .toml or a zed .json", .{src});
+ stems[stem_index] = stem;
+ stem_index += 1;
+ } else {
+ theme_index += try readZed(arena, gpa, src, text, themes[theme_index..]);
+ }
+ }
+ std.debug.assert(stem_index == stems.len);
+ for (stems) |stem| {
+ themes[theme_index] = try helixTheme(arena, stem, resolve(arena, &tomls, stem, 8));
+ theme_index += 1;
}
- for (stems.items) |stem| try helixTheme(arena, stem, resolve(arena, &tomls, stem, 8), &themes);
+ std.debug.assert(theme_index == themes.len);
// sorted, so the ring's tail is the same on every machine whatever order
// the directory happened to be read in
- std.mem.sort(Theme, themes.items, {}, byName);
+ std.mem.sort(Theme, themes, {}, byName);
var dir = try std.Io.Dir.cwd().openDir(io, args[1], .{});
defer dir.close(io);
var buf: std.Io.Writer.Allocating = .init(arena);
- for (themes.items) |t| {
+ for (themes) |t| {
buf.clearRetainingCapacity();
try emit(&buf.writer, t);
try write(io, dir, arena, t.name, buf.written());
@@ -113,7 +135,7 @@ pub fn main(init: std.process.Init) !void {
// from whoever can. pardes.zig folds its declarations into the ring.
buf.clearRetainingCapacity();
try buf.writer.writeAll("// Generated by tools/gen_themes.zig. Do not edit.\n");
- for (themes.items) |t| try buf.writer.print("pub const {s} = @import(\"{s}.zig\");\n", .{ t.name, t.name });
+ for (themes) |t| try buf.writer.print("pub const {s} = @import(\"{s}.zig\");\n", .{ t.name, t.name });
try write(io, dir, arena, "list", buf.written());
}
@@ -160,13 +182,20 @@ fn rgb(w: *std.Io.Writer, c: Rgb) !void {
/// single word (a `Theme <name>` row is executed by splitting on the first
/// space) and a legal identifier (it is a decl in list.zig).
fn slug(arena: std.mem.Allocator, raw: []const u8) ![]const u8 {
- var out: std.ArrayList(u8) = .empty;
+ const out = try arena.alloc(u8, raw.len);
+ var len: usize = 0;
for (raw) |c| {
- if (std.ascii.isAlphanumeric(c)) try out.append(arena, std.ascii.toLower(c))
- // one underscore per run of punctuation, and never a trailing one
- else if (out.items.len > 0 and out.items[out.items.len - 1] != '_') try out.append(arena, '_');
+ if (std.ascii.isAlphanumeric(c)) {
+ out[len] = std.ascii.toLower(c);
+ len += 1;
+ } else if (len > 0 and out[len - 1] != '_') {
+ // one underscore per run of punctuation; it is trimmed below when
+ // the run ends the name
+ out[len] = '_';
+ len += 1;
+ }
}
- const s = std.mem.trim(u8, out.items, "_");
+ const s = std.mem.trim(u8, out[0..len], "_");
if (s.len == 0 or !std.ascii.isAlphabetic(s[0])) std.process.fatal("theme name {s} does not slug to an identifier", .{raw});
return s;
}
@@ -284,7 +313,7 @@ fn resolve(arena: std.mem.Allocator, tomls: *const std.StringHashMapUnmanaged(To
/// kw/str/comment their scope's fg, else ink (helix: unstyled = ui.text)
/// num constant.numeric.fg, else constant.fg, else ink
/// lineno ui.linenr.fg, else ink
-fn helixTheme(arena: std.mem.Allocator, stem: []const u8, h: Helix, out: *std.ArrayList(Theme)) !void {
+fn helixTheme(arena: std.mem.Allocator, stem: []const u8, h: Helix) !Theme {
const fg = h.get("ui.text", .fg);
const bg = h.get("ui.background", .bg);
const ink = fg orelse ansi_default[7];
@@ -324,7 +353,7 @@ fn helixTheme(arena: std.mem.Allocator, stem: []const u8, h: Helix, out: *std.Ar
// one line up, which is the reverse-video case again: those two would paint
// the highlight in one colour on itself.
const named_ink = h.get("ui.selection", .fg);
- try out.append(arena, .{
+ return .{
.name = try slug(arena, stem),
// `palette` stays null: helix leaves the colors a PROGRAM emits alone,
// and so do we.
@@ -349,7 +378,7 @@ fn helixTheme(arena: std.mem.Allocator, stem: []const u8, h: Helix, out: *std.Ar
.scroll_track = h.get("ui.statusline", .bg) orelse page,
.scroll_thumb = sel,
.palette = null,
- });
+ };
}
/// helix lets a theme name one of the terminal's sixteen colors instead of an
@@ -510,7 +539,19 @@ fn table(s: []const u8) Pair {
// take them: a light theme is only usable if the shell's own output is
// repainted for it, and pardes's acme-light is the hand-written proof.
-fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, text: []const u8, out: *std.ArrayList(Theme)) !void {
+fn zedThemeCount(gpa: std.mem.Allocator, path: []const u8, text: []const u8) !usize {
+ const parsed = std.json.parseFromSlice(std.json.Value, gpa, text, .{}) catch
+ std.process.fatal("{s}: not valid JSON", .{path});
+ defer parsed.deinit();
+ const root = switch (parsed.value) {
+ .object => |o| o,
+ else => std.process.fatal("{s}: top level is not an object", .{path}),
+ };
+ const list = root.get("themes") orelse std.process.fatal("{s}: no `themes` array", .{path});
+ return list.array.items.len;
+}
+
+fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, text: []const u8, out: []Theme) !usize {
const parsed = std.json.parseFromSlice(std.json.Value, gpa, text, .{}) catch
std.process.fatal("{s}: not valid JSON", .{path});
defer parsed.deinit();
@@ -519,7 +560,8 @@ fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, t
else => std.process.fatal("{s}: top level is not an object", .{path}),
};
const list = root.get("themes") orelse std.process.fatal("{s}: no `themes` array", .{path});
- for (list.array.items) |variant| {
+ std.debug.assert(list.array.items.len <= out.len);
+ for (list.array.items, 0..) |variant, index| {
const v = variant.object;
const z: Zed = .{
.path = path,
@@ -531,7 +573,7 @@ fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, t
pal[i] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.{s}", .{n}));
pal[i + 8] = z.need(try std.fmt.allocPrint(arena, "terminal.ansi.bright_{s}", .{n}));
}
- try out.append(arena, .{
+ out[index] = .{
// `_zed` on every one of them: see the naming note at the top —
// zed re-ships gruvbox, ayu and one, so the source is part of the
// name rather than a suffix that appears only on the eight that
@@ -563,8 +605,9 @@ fn readZed(arena: std.mem.Allocator, gpa: std.mem.Allocator, path: []const u8, t
.scroll_track = z.need("scrollbar.thumb.border"),
.scroll_thumb = z.need("scrollbar.thumb.background"),
.palette = pal,
- });
+ };
}
+ return list.array.items.len;
}
/// zed's order, which is also the ANSI one: 0-7 plain, 8-15 the `bright_` half