diff options
| -rw-r--r-- | build.zig | 91 | ||||
| -rw-r--r-- | mupdf.zig | 36 | ||||
| -rw-r--r-- | src/allocators.zig | 113 | ||||
| -rw-r--r-- | src/builtins.zig | 154 | ||||
| -rw-r--r-- | src/config.zig | 2 | ||||
| -rw-r--r-- | src/file_pane.zig | 65 | ||||
| -rw-r--r-- | src/grammar_manifest.zig | 44 | ||||
| -rw-r--r-- | src/gui/fonts.zig | 16 | ||||
| -rw-r--r-- | src/gui/gui.zig | 475 | ||||
| -rw-r--r-- | src/look.zig | 191 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 227 | ||||
| -rw-r--r-- | src/macos.zig | 179 | ||||
| -rw-r--r-- | src/main.zig | 1 | ||||
| -rw-r--r-- | src/modal.zig | 252 | ||||
| -rw-r--r-- | src/output_pane.zig | 227 | ||||
| -rw-r--r-- | src/pardes.zig | 1504 | ||||
| -rw-r--r-- | src/pdf.zig | 388 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 112 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 21 | ||||
| -rw-r--r-- | src/syntax.zig | 198 | ||||
| -rw-r--r-- | src/term_pane.zig | 55 | ||||
| -rw-r--r-- | src/tty/tty.zig | 183 | ||||
| -rw-r--r-- | src/web.zig | 165 | ||||
| -rw-r--r-- | test/e2e_harness.zig | 50 | ||||
| -rw-r--r-- | test/hxdiff.zig | 163 | ||||
| -rw-r--r-- | test/image_harness.zig | 133 | ||||
| -rw-r--r-- | test/lspbench.zig | 29 | ||||
| -rw-r--r-- | test/pdf_bench.zig | 29 | ||||
| -rw-r--r-- | test/pdf_sections_bench.zig | 61 | ||||
| -rw-r--r-- | test/perf.zig | 94 | ||||
| -rw-r--r-- | test/snapshot.zig | 372 | ||||
| -rw-r--r-- | tools/embed_zig_sources.zig | 75 | ||||
| -rw-r--r-- | tools/gen_themes.zig | 93 |
33 files changed, 3849 insertions, 1949 deletions
@@ -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; } @@ -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 |
