//! Output panes: acme's +Errors, a file pane with no file behind it, holding //! text the core produced itself (+Search results, +Help, the LSP answer //! buffers). It IS a file pane — every mode, motion, chord and look works for //! free — and that reuse is the point. //! //! What it is NOT any more is a file pane with a bool on it. An output buffer //! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it //! gets is one `traits` lookup on that field. Before this, a dozen places //! re-derived what a pane was from the outside, each asking a different wrong //! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS, //! which is backwards — a name is a consequence), a `search_kind` field on the //! pane that ran the search rather than on the buffer that answered it, and //! `f.output` booleans sprinkled through kind-agnostic layout code. Now the //! buffer knows, and the table below is the whole answer: one screen you read //! top to bottom to see every way an output pane differs from a file, and one //! row to add to introduce another kind. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const file_pane = @import("file_pane.zig"); const modal = @import("modal.zig"); const builtins = @import("builtins.zig"); const runtime_config = @import("runtime_config.zig"); const effect_sources = @import("effect_sources.zig"); const Builtin = builtins.registry.Builtin(); const config = @import("config.zig"); const build_options = @import("pardes_config"); const dump = @import("dump.zig"); const lsp = @import("lsp/lsp.zig"); const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); /// the installed fonts, for openFonts. GUI only, behind the same comptime /// branch builtins.zig imports it through — see the note there. const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; /// What opened this buffer — THE field, and the only input to `traits`. /// /// Two vocabularies because the core has exactly two: a `Builtin` is a word /// you can execute anywhere, and that covers Find, Grep, Help and every /// language query that has a name (Hover, Diagnostics...). The rest are KEYS — /// helix binds the five gotos and `=` as motions and `/` as a search input, /// and a motion has no word to click. Recording the key's `lsp.Kind` (or /// `.search` for the bare `/`) is not a parallel tag enum: both are the values /// the caller already holds when it opens the buffer. pub const Origin = union(enum) { cmd: Builtin, query: lsp.Kind, /// the bare `/` — the pane's own text, searched in core search, }; /// The exact command identity. A search prompt is bounded by the same one-line /// cap as a tag, so retaining that whole bound keeps refill and dump/restore /// identity exact without adding a per-output allocation. pub const max_arg = dump.max_origin_arg; /// An output buffer's own state, hung off `file_pane.State.output`. pub const Output = struct { from: Origin, /// the command's ARGUMENT: the pattern a Grep matched, the new name a /// Rename took, the SPC prefix a Help lists. Inline rather than allocated: /// tag input already enforces this exact cap. arg_buf: [max_arg]u8 = undefined, arg_len: u16 = 0, pub fn arg(o: *const Output) []const u8 { return o.arg_buf[0..o.arg_len]; } }; pub fn setArg(o: *Output, text: []const u8) error{ArgumentTooLong}!void { if (text.len > max_arg) return error.ArgumentTooLong; o.arg_len = @intCast(text.len); @memcpy(o.arg_buf[0..o.arg_len], text); } /// Every way an output pane differs from a file pane. Manual builtins already /// declare this exact row beside their implementation; use that schema here /// too instead of copying it into a parallel struct. pub const Traits = builtins.OutputTraits; /// A REAL file pane, as a row of the same table — so kind-agnostic code asks /// one question and gets one answer whichever it is holding. `name` is unused: /// a file already has a path. const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; /// THE TABLE. Everything above, answered from the command that opened the /// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening /// builtin should not compile until someone has said what its buffer does. 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| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, // prose: an action list, a diff, a report about the backend .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, // Rename responses are edits consumed before an output can open; // the exhaustive table still records the otherwise-unused trait. .rename => .{ .name = config.lsp_buffer }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, .jumps = true, }, // completion lists WHAT COULD GO HERE, one row per candidate's // definition. It does not jump on a single row where the gotos do: // a goto answers a question whose answer is a place, so landing // there IS the answer, whereas the question here is "what can I // write", and being teleported into the one candidate's // declaration instead of being shown it is not that. .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ .name = config.search_buffer, .steps = true, }, }, }; } /// The same table asked of a file pane's `output` field, null (a real file) /// included. This is what the kind-agnostic code in pardes.zig calls. pub fn fileTraits(out: ?Output) Traits { return traits((out orelse return file_row).from); } /// How much of a row ONE n/N step selects (Pardes.lookSpanIn). Derived from /// the two columns above rather than a third one, because it is not a fact /// about a buffer — it is what those facts MEAN to the walk. pub const Grain = enum { /// every look-able word, several to a line, in document order. Free text: /// a terminal's scrollback, a file, a PDF, and an output buffer of PROSE, /// where the place you want may be mid-sentence. word, /// the location at the head of the row, and one stop per row. A results /// buffer is a LIST: the words after a row's location are the matched /// text, and stepping onto them was stepping onto the same hit twice. line, /// the whole row: a command list, where the line is the word. whole, }; /// The grain of a pane's rows, off its `output` field — null (a real file) /// included, which is why a file pane is unaffected by any of this. pub fn grain(out: ?Output) Grain { const tr = fileTraits(out); if (tr.commands) return .whole; return if (tr.steps) .line else .word; } /// How the dump spells an origin. A WORD, never an integer, for the reason the /// dump already stores tag words: reordering builtins.zig stays free. Nothing /// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is /// neither. pub fn word(o: Origin) []const u8 { return switch (o) { .cmd => |b| @tagName(b), .query => |k| @tagName(k), .search => "/", }; } /// the inverse; null for "" (a real file) and for a word this build no longer /// has, which is a dump from another version and not a reason to fail a load pub fn fromWord(w: []const u8) ?Origin { if (w.len == 0) return null; if (std.mem.eql(u8, w, "/")) return .search; if (std.meta.stringToEnum(Builtin, w)) |b| if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; return null; } /// Is the results buffer `pane`'s n/N is armed on the one `from` filled? /// `]d`/`[d` are the only keys that care WHICH search is showing — they step /// the diagnostics list when it is up and ask for one when it is not — and /// this is how they ask now that the buffer remembers: `search_pane` is a /// SLOT, so this doubles as the check that the slot is still ours. pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { const rp = p.panes[pane.search_pane orelse return false] orelse return false; const f = rp.file orelse return false; const o = f.output orelse return false; return std.meta.eql(o.from, from); } /// Open one. `content` is gpa-owned and adopted; the NAME comes from the table /// (the caller says what ran, not what to call it) and carries `dir` so looks /// inside the buffer resolve like anywhere else. pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), traits(from).name, }); errdefer p.gpa.free(path); var out: Output = .{ .from = from }; try setArg(&out, arg); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content, .output = out }; pane.cur_pinned = true; return pane; } /// Land a freshly produced list of rows in the buffer it belongs in — the one /// rule every results buffer follows, whichever side of the core made them. /// /// The SAME command asked again REFILLS the list it already opened rather than /// stacking a byte-identical twin under the pane. That was runSearch's rule /// from the start (right-clicking a word in four places is one +Search walked /// four times) and language answers turned out to need it far more urgently: /// Tab after a dot makes a query an ordinary typing keystroke, and without the /// refill twenty of them fill every slot and the key is eaten for the rest of /// the session — see docs/lsp.md. /// /// What "the same command" means comes off the ORIGIN. A search is identified /// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves /// bar's open; a language query is asked about a different symbol every time /// with the same (usually empty) arg, so the arg cannot tell two apart and the /// KIND is the natural unit — a second `gr` replaces the first list. Same /// directory only, because the rows are written relative to it, and never the /// asking pane itself (a `/` inside a +Search writes its own rows). /// /// `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 { 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; const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = if (rf.output) |*x| x else continue; if (!std.meta.eql(o.from, from)) continue; if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; try setArg(o, arg); // a refill that changes NOTHING keeps its place: a right click on an // already-armed word is an `n`, and throwing the list back to the top // only to scroll down to the stepped row is a jump with no information // in it. const same = std.mem.eql(u8, rf.content, content); file_pane.setContent(p, rf, content); if (!same) rf.scroll = 0; p.active = id; if (traits(from).steps) { pane.search_pane = i; pane.search_row = anchor; p.armLookWalk(i); } 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 // nowhere, so only stepping buffers arm the stepper — and WHICH command // filled it is the buffer's own record, not a field on the asking pane. if (traits(from).steps) { pane.search_pane = free; pane.search_row = anchor; p.armLookWalk(free); } } /// 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 /// for nothing: the leading word is an ordinary look target, and the ordinary /// look path is what goes there. /// /// A RENDERING, never a second list. The rows are spelled from the stack at /// the moment you ask and go stale the moment you jump, exactly like a search /// result — which is also why this opens a fresh buffer per press instead of /// refreshing one the way Help does: Help is a document, this is a snapshot. /// /// How a location is spelled is the rule runSearch already follows: a REAL /// file names itself (its path is absolute, so the row resolves from any /// pane's directory), and everything else — a terminal, an output buffer, an /// 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 { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); 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) try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) else try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); } const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .Jumplist }, content); } /// The ThemeSel builtin: the theme ring written out as one `Theme ` row /// per theme — the ordinary builtin with its argument, exactly the line you /// would type — into a buffer whose `commands` trait says the rows are words /// and not places. n/N therefore select each row WHOLE and Tab runs it, so /// walking the list is trying the themes on and stopping on one is choosing /// it: no picker mode, no preview state, nothing to commit or cancel. /// /// The command's own name comes from the runtime setting descriptor rather /// than a second literal. The descriptor generates the builtin too, so a /// rename cannot leave picker rows naming a command that is gone. pub fn openThemes(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (pardes.themes) |t| try out.writer.print(comptime runtime_config.findAction(.theme).?.word ++ " {s}\n", .{t.name}); const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .ThemeSel }, content); } /// The FontSel builtin: openThemes over the fonts installed on the machine /// instead of the themes compiled into the binary — one `Font ` row /// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and /// stopping picks one. Everything that makes that work is already above; this /// is the same one-pass writer pointed at a different list. /// /// Only where the shell draws its own text. The same `font_picker` availability /// bit that generates the Font/FontSel builtins keeps non-GUI builds from /// analysing font discovery here. pub fn openFonts(p: *Pardes, id: usize) !void { if (builtins.capabilities.font_picker) { const arena = p.scratch.allocator(); const font_list = fonts.list(arena, null); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (font_list) |f| try out.writer.print(comptime runtime_config.findAction(.font).?.word ++ " {s}\n", .{f.name}); const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .FontSel }, content); } } /// Open a buffer n/N will walk, and arm them on it: the shared tail of every /// builtin that answers with a list. `content` is gpa-owned and adopted by the /// new pane, or freed here if opening it fails. /// /// 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, content: []u8) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); 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; pane.search_row = null; p.armLookWalk(free); } /// The Help builtin: THE INDEX of builtins — every one of them, and every way /// to run it — filtered to what `prefix` can still reach, written into an /// output buffer (acme's +Errors). Ordinary text, so the names in it are LIVE: /// middle-click `Tutor` there and the tutor opens. Reuses the open +Help /// buffer instead of piling panes up, and focus follows: you asked to read it. /// /// ONE builtin and not two. The complete index and the mid-chord "what can /// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a /// different prefix — the empty one matches every row, including the builtins /// 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. fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { 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 gpa.alloc(u8, len); var at: usize = 0; if (prefix.len == 0) { @memcpy(content[0..full_header.len], full_header); at = full_header.len; } else { @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| { if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; @memcpy(content[at..][0..row.line.len], row.line); at += row.line.len; content[at] = '\n'; at += 1; } std.debug.assert(at == content.len); return content; } pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { const content = try helpContent(p.gpa, prefix); // content is handed off unfreed on purpose: openRead adopts it or frees // it, and nothing between the alloc above and this line can fail. return openRead(p, id, .{ .cmd = .Help }, prefix, content); } test "full Help renders every enabled builtin row" { const content = try helpContent(std.testing.allocator, ""); defer std.testing.allocator.free(content); const body = content[(std.mem.lastIndexOf(u8, content, "\n\n") orelse return error.MissingHelpHeader) + 2 ..]; var lines = std.mem.splitScalar(u8, body, '\n'); for (pardes.builtin_rows) |row| try std.testing.expectEqualStrings(row.line, lines.next() orelse return error.MissingBuiltinHelpRow); // The renderer terminates every row with a newline, so only split's empty // trailing field may remain. Any extra non-empty field is an unregistered // Help row and any missing row already failed in the loop above. try std.testing.expectEqualStrings("", lines.next() orelse return error.MissingHelpTerminator); try std.testing.expect(lines.next() == null); } /// The complete live Config report: the generated settings and the host facts /// needed to interpret them. The startup path remains ordinary selectable text /// in the report, so Look still opens the exact file the launcher consulted. pub fn openConfig(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try runtime_config.writeReport(&out.writer, .{ .startup_config_path = p.opts.startup_config_path, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, .compiled_default_shell = config.default_shell, .gui_shader_source_mode = if (gui_shader_source_mode) |mode| mode.label() else null, .hover_delay_frames = config.look_preview_delay_frames, .native_images = p.native_images, .capabilities = builtins.capabilities, .state = &p.settings, }); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .Config }, "", content); } /// The version banner plus the embedded CHANGELOG, so an installed binary can /// say what it is and what changed without a repository beside it. pub fn openChangelog(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try out.writer.print("pardes {s}\n\n", .{build_options.version}); try out.writer.writeAll(@embedFile("CHANGELOG.md")); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .Changelog }, "", content); } /// Print the implementation that this build actually uses for one effect. /// Sources are build inputs embedded as bytes, so this stays useful from an /// installed binary with no repository beside it. pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { const name = std.mem.trim(u8, argument, " \t\r\n"); const setting = runtime_config.find(name) orelse return error.UnknownEffect; switch (setting.action) { .transition, .scene => {}, else => return error.NotAnEffect, } if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; const segments = effect_sources.forSetting(setting) orelse return error.EffectUnavailable; var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); if (gui_shader_source_mode) |mode| try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); try out.writer.writeByte('\n'); for (segments) |segment| { try out.writer.print("--- {s} ---\n", .{segment.path}); try out.writer.writeAll(segment.source); if (!std.mem.endsWith(u8, segment.source, "\n")) try out.writer.writeByte('\n'); try out.writer.writeByte('\n'); } const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); } /// Open a buffer you READ, and go there: the shared tail of every builtin /// whose answer is a document rather than a list. Asking again REFRESHES the /// one already open instead of stacking a twin beside it — found by its /// ORIGIN, never by matching its name, for the reason the whole file exists. /// /// The mirror of `openStepped`, and the difference is the two lines at the /// ends: focus comes HERE (you asked to read it) where a results buffer /// leaves you in the pane that asked, and n/N are not armed, because prose /// has nowhere to step to. /// /// `content` is gpa-owned: adopted by the buffer, or freed here when there is /// nowhere to put it. fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; const ho = if (hf.output) |*o| o else continue; if (!std.meta.eql(ho.from, from)) continue; try setArg(ho, arg); file_pane.setContent(p, hf, content); hf.scroll = 0; hp.cur_row = 0; hp.msel.active = false; p.active = i; return; } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); 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 = free; } test "dump origins accept only builtins that actually own output panes" { try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); try std.testing.expect(fromWord("Kill") == null); try std.testing.expect(fromWord("Theme") == null); } test "result refill identity retains the full bounded argument" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24, }); defer p.deinit(); var first: [max_arg]u8 = @splat('a'); var second = first; first[200] = 'x'; second[200] = 'y'; try fillResults( p, 0, "/tmp", .search, &first, try p.gpa.dupe(u8, "first\n"), null, ); const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; const next_slot = p.freeSlot(); try fillResults( p, 0, "/tmp", .search, &first, try p.gpa.dupe(u8, "refilled\n"), null, ); try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); try std.testing.expectEqual(next_slot, p.freeSlot()); try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); try fillResults( p, 0, "/tmp", .search, &second, try p.gpa.dupe(u8, "second\n"), null, ); try std.testing.expect(p.panes[0].?.search_pane.? != first_id); var output: Output = .{ .from = .search }; var oversized: [max_arg + 1]u8 = @splat('z'); try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); const slot_before_error = p.freeSlot(); try std.testing.expectError( error.ArgumentTooLong, fillResults( p, 0, "/tmp", .search, &oversized, try p.gpa.dupe(u8, "must be freed\n"), null, ), ); try std.testing.expectEqual(slot_before_error, p.freeSlot()); }