From d126715cd20a39dbc10870318968546f5e371b97 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 22:42:54 -0300 Subject: Split the pipe's editor side out of selection_pipe.zig into Pipe.zig Pure move, no behaviour change: the editor half of `|` that the earlier change put at the end of selection_pipe.zig (PendingPipe, pipeMarker, submitPipe, pipeRequest, pipeFailed, pipeCut, pipeOutput, pipeResponse, and the eight pipe tests with nextPipeEffect) now lives in its own Pipe.zig, so the pipe's editor state can become Pipe's own fields next. selection_pipe.zig goes back to exactly what it was before this series: the native runner and the boundary values the shells hand to their workers. The moved code names the runner's types as `selection_pipe.X` again, as it did in pardes.zig; callers change from `selection_pipe.submitPipe(p, ..)` to `Pipe.submitPipe(p, ..)` (pardes.zig and the four shells). Co-Authored-By: Claude Opus 5.5 --- src/Pipe.zig | 534 +++++++++++++++++++++++++++++++++++++++++++++++ src/detached/server.zig | 3 +- src/fs.zig | 1 + src/gui/gui.zig | 3 +- src/macos.zig | 3 +- src/pardes.zig | 10 +- src/selection_pipe.zig | 542 +----------------------------------------------- src/tty/tty.zig | 3 +- 8 files changed, 554 insertions(+), 545 deletions(-) create mode 100644 src/Pipe.zig (limited to 'src') diff --git a/src/Pipe.zig b/src/Pipe.zig new file mode 100644 index 00000000..0fde654a --- /dev/null +++ b/src/Pipe.zig @@ -0,0 +1,534 @@ +//! The pipe inside the editor (`|`): the prompt, the request the core emits +//! for the shell to run through selection_pipe.zig, and the atomic edit its +//! answer makes. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const modal = @import("modal.zig"); +const panes = @import("panes.zig"); +const config = @import("config.zig"); +const selection_pipe = @import("selection_pipe.zig"); +const edit = @import("edit.zig"); +const Pane = panes.Pane; +const Key = pardes.Key; +const Pardes = pardes.Pardes; + +fn nextPipeEffect(p: *Pardes) ?u32 { + while (p.nextEffect()) |effect| switch (effect) { + .pipe => |request| return request.id, + else => {}, + }; + return null; +} + +/// The tag-tail marker each behaviour arms with. One function so the prompt +/// that is DRAWN and the command that is PARSED can never disagree. +pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { + return switch (how) { + .replace => config.pipe_marker, + .ignore => config.pipe_marker_to, + .insert => config.pipe_marker_insert, + .append => config.pipe_marker_append, + }; +} + +pub const PendingPipe = struct { + id: u32, + pane: usize, + serial: u32, + revision: u32, + command: []u8, + cwd: []u8, + inputs: []selection_pipe.Input, + ranges: [Pane.max_selections]modal.Selection, + primary: u8, + explicit: bool, + how: modal.Normal.PipeBehavior, + nranges: u8, + + pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { + gpa.free(wait.command); + gpa.free(wait.cwd); + for (wait.inputs) |input| gpa.free(@constCast(input.bytes)); + gpa.free(wait.inputs); + wait.* = undefined; + } + + fn request(wait: *const PendingPipe) selection_pipe.Request { + return .{ + .id = wait.id, + .command = wait.command, + .cwd = wait.cwd, + .inputs = wait.inputs, + }; + } +}; + +pub fn submitPipe(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const f = pane.file orelse return; + if (!panes.Output.fileTraits(f.output).saves) return; + const prompt = switch (pane.prompt) { + .pipe => |pipe| pipe, + else => return, + }; + const tail = pane.tagSlice(); + const armed = tail[@min(prompt.at, tail.len)..]; + const marker = pipeMarker(prompt.how); + if (!std.mem.startsWith(u8, armed, marker)) return; + const command = armed[marker.len..]; + if (command.len == 0) return; + + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(f.content, 0, &ranges); + // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. + const ninputs = if (prompt.how.pipes()) got.n else 1; + const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; + var made: usize = 0; + for (ranges[0..ninputs], 0..) |range, i| { + const bytes = if (prompt.how.pipes()) bytes: { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (hi > f.content.len) break; + break :bytes f.content[lo..hi]; + } else ""; + const copy = p.gpa.dupe(u8, bytes) catch break; + inputs[i] = .{ .bytes = copy }; + made += 1; + } + if (made != ninputs) { + for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes)); + p.gpa.free(inputs); + return; + } + const command_copy = p.gpa.dupe(u8, command) catch { + for (inputs) |input| p.gpa.free(@constCast(input.bytes)); + p.gpa.free(inputs); + return; + }; + const cwd_copy = p.gpa.dupe(u8, Pardes.paneDir(pane)) catch { + p.gpa.free(command_copy); + for (inputs) |input| p.gpa.free(@constCast(input.bytes)); + p.gpa.free(inputs); + return; + }; + + p.pipe_seq +%= 1; + if (p.pipe_wait) |*old| old.deinit(p.gpa); + p.pipe_wait = .{ + .id = p.pipe_seq, + .pane = id, + .serial = pane.serial, + .revision = f.revision, + .command = command_copy, + .cwd = cwd_copy, + .inputs = inputs, + .ranges = ranges, + .primary = @intCast(got.pri), + .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, + .how = prompt.how, + .nranges = @intCast(got.n), + }; + p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); +} + +/// Frontends call this while draining a .pipe effect, then immediately +/// copy the borrowed view into a worker-owned selection_pipe.Job. +pub fn pipeRequest(p: *const Pardes, id: u32) ?selection_pipe.Request { + if (p.pipe_wait) |*wait| { + if (wait.id == id) return wait.request(); + } + return null; +} + +fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + defer out.deinit(); + const w = &out.writer; + w.print("| {s}\n\n", .{wait.command}) catch return; + if (failure) |fail| { + if (wait.inputs.len > 1) + w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return; + switch (fail.kind) { + .exit => w.print("exit status {d}\n", .{fail.code}) catch return, + .signal => w.writeAll("killed by a signal\n") catch return, + .timeout => w.print( + "still running after {d} seconds, and stopped\n", + .{selection_pipe.command_timeout_seconds}, + ) catch return, + .too_large => w.writeAll("produced more output than a filter may return\n") catch return, + .spawn => w.writeAll("could not be started\n") catch return, + .io => w.writeAll("could not be read\n") catch return, + } + if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; + } else { + // No diagnosis at all: nobody ran it. The detached daemon, the + // browser and the board all leave `pipe` null on purpose. + w.writeAll("this session cannot run filters\n") catch return; + } + const content = out.toOwnedSlice() catch return; + const was = p.active; + panes.Output.openErrors(p, wait.pane, content) catch |err| { + p.gpa.free(content); + // Nowhere to put the report is itself worth one line. + p.reportError(wait.pane, "pipe", err); + return; + }; + if (p.panes[was] != null) p.active = was; +} + +fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { + const range = wait.ranges[i]; + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (hi > content.len) return null; + return switch (wait.how) { + .replace, .ignore => .{ .lo = lo, .hi = hi }, + .insert => .{ .lo = lo, .hi = lo }, + .append => .{ .lo = hi, .hi = hi }, + }; +} + +fn pipeOutput( + wait: *const PendingPipe, + outputs: []const []const u8, + content: []const u8, + i: usize, +) []const u8 { + var out = if (wait.how.pipes()) outputs[i] else outputs[0]; + const range = wait.ranges[i]; + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + const selected = if (hi <= content.len) content[lo..hi] else ""; + if (!std.mem.endsWith(u8, selected, "\n") and std.mem.endsWith(u8, out, "\n")) { + out = out[0 .. out.len - 1]; + if (std.mem.endsWith(u8, out, "\r")) out = out[0 .. out.len - 1]; + } + return out; +} + +pub fn pipeResponse( + p: *Pardes, + id: u32, + success: bool, + outputs: []const []const u8, + failure: ?selection_pipe.Failure, +) void { + // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is + // the answer to a question nobody is still asking. + if (p.pipe_wait == null or p.pipe_wait.?.id != id) return; + var wait = p.pipe_wait.?; + p.pipe_wait = null; + defer wait.deinit(p.gpa); + + if (!success or outputs.len != wait.inputs.len) return pipeFailed(p, &wait, failure); + + const pane = p.panes[wait.pane] orelse return; + if (pane.serial != wait.serial) return; + const f = if (pane.file) |*file| file else return; + if (!panes.Output.fileTraits(f.output).saves) return; + if (f.revision != wait.revision) + return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); + + if (wait.how == .ignore) return; + + const n = wait.nranges; + var total_output: usize = 0; + var removed: usize = 0; + var previous_end: usize = 0; + for (0..n) |i| { + const cut = pipeCut(&wait, f.content, i) orelse return; + const output = pipeOutput(&wait, outputs, f.content, i); + if (output.len > selection_pipe.max_stdout_bytes) return; + total_output = std.math.add(usize, total_output, output.len) catch return; + if (total_output > selection_pipe.max_total_stdout_bytes) return; + if (cut.lo < previous_end) return; + removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return; + previous_end = cut.hi; + } + const kept = f.content.len - removed; + const final_len = std.math.add(usize, kept, total_output) catch return; + const replacement = p.gpa.alloc(u8, final_len) catch return; + + var new_ranges: [Pane.max_selections]modal.Selection = undefined; + var read_at: usize = 0; + var write_at: usize = 0; + for (0..n) |i| { + const range = wait.ranges[i]; + const cut = pipeCut(&wait, f.content, i).?; + const output = pipeOutput(&wait, outputs, f.content, i); + @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]); + write_at += cut.lo - read_at; + const out_start = write_at; + @memcpy(replacement[write_at .. write_at + output.len], output); + write_at += output.len; + const out_end = write_at; + new_ranges[i] = if (range.anchor > range.head) + .{ .anchor = out_end, .head = out_start } + else + .{ .anchor = out_start, .head = out_end }; + read_at = cut.hi; + } + @memcpy(replacement[write_at..], f.content[read_at..]); + + // One async request is one history transaction, even at 64 cursors. + edit.pushUndo(p, pane); + panes.File.setContent(p, f, replacement); + pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); + pane.select = false; + pane.ensureCursorVisible(); +} + +test "selection pipe prompt submits exact request and Escape cancels" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + + p.update(.{ .key = .{ .cp = '|' } }); + try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); + 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.prompt != .pipe); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; + try std.testing.expectEqualStrings("tr a-z A-Z", request.command); + try std.testing.expectEqualStrings("/", request.cwd); + try std.testing.expectEqual(@as(usize, 1), request.inputs.len); + try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes); + + const before = pane.file.?.content; + 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.prompt != .pipe); + try std.testing.expectEqualSlices(u8, before, pane.file.?.content); + try std.testing.expect(nextPipeEffect(p) == null); +} + +test "selection pipe replaces all ranges atomically and undo restores them" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 6, .head = 8 }, + }; + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); + + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; + try std.testing.expectEqual(@as(usize, 2), request.inputs.len); + try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); + try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); + + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); + const outputs: []const []const u8 = &.{ "AA\n", "" }; + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); + try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); + try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); + + p.update(.{ .key = .{ .cp = 'u' } }); + try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); +} + +test "the four shell behaviours put their output where helix puts it" { + const gpa = std.testing.allocator; + const Case = struct { + key: Key, + outputs: []const []const u8, + want: []const u8, + want_effect: bool = true, + }; + // One selection, `bb`, in `aa bb cc\n` — bytes 3..5. + for ([_]Case{ + // `|` replaces the selection with what the command wrote. + .{ .key = .{ .cp = '|' }, .outputs = &.{"XX"}, .want = "aa XX cc\n" }, + // `!` inserts BEFORE it, and the selection survives to its right. + .{ .key = .{ .cp = '!' }, .outputs = &.{"XX"}, .want = "aa XXbb cc\n" }, + // `A-!` appends AFTER it. + .{ .key = .{ .cp = '!', .alt = true }, .outputs = &.{"XX"}, .want = "aa bbXX cc\n" }, + // `A-|` runs the command and leaves the text completely alone: the + // point is the side effect, not the output. + .{ .key = .{ .cp = '|', .alt = true }, .outputs = &.{"XX"}, .want = "aa bb cc\n" }, + }) |case| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("aa bb cc\n"); + pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); + + p.update(.{ .key = case.key }); + for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = case.outputs } }); + try std.testing.expectEqualSlices(u8, case.want, pane.file.?.content); + } +} + +test "a command with no stdin runs once and every cursor gets that one answer" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 6, .head = 8 }, + }; + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); + + p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin + for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; + + try std.testing.expectEqual(@as(usize, 1), request.inputs.len); + try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); + + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); + try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); +} + +test "selection pipe failure and stale completion never mutate the file" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("abc\n"); + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + 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.?.history.undo_len); + + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + panes.File.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.?.history.undo_len); +} + +test "a failed filter opens an errors buffer carrying the command's own words" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("abc\n"); + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + + p.update(.{ .key = .{ .cp = '|' } }); + for ("trr") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + + // The shape a real runner brings home for `| trr`: nonzero exit, and the + // shell's own sentence about it. + var stderr = "sh: line 1: trr: command not found\n".*; + p.update(.{ .pipe_resp = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 127, .stderr = &stderr }, + } }); + + // The text is untouched — a failed filter is not an edit... + try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + // ...and the cursor did not go anywhere, so `|` again edits the same file. + try std.testing.expectEqual(@as(usize, 0), p.active); + + var found: ?[]const u8 = null; + for (p.panes) |slot| { + const q = slot orelse continue; + const qf = q.file orelse continue; + const o = qf.output orelse continue; + if (std.meta.activeTag(o.from) == .errors) found = qf.content; + } + const report = found orelse return error.NoErrorsBuffer; + try std.testing.expect(std.mem.indexOf(u8, report, "| trr") != null); + try std.testing.expect(std.mem.indexOf(u8, report, "exit status 127") != null); + try std.testing.expect(std.mem.indexOf(u8, report, "command not found") != null); +} + +test "selection pipe rejects a reused pane slot and a superseded request" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + var pane = try p.setTestFile("old\n"); + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + pane = try p.setTestFile("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.?.history.undo_len); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + p.update(.{ .key = .{ .cp = '|' } }); + p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + try std.testing.expect(first_id != latest_id); + + p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } }); + 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.?.history.undo_len); +} + +test "selection pipe binding is file-normal-only" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("x"); + + pane.file.?.output = .{ .from = .search }; + p.update(.{ .key = .{ .cp = '|' } }); + try std.testing.expect(pane.prompt != .pipe); + pane.file.?.output = null; + pane.mode = .insert; + p.update(.{ .key = .{ .cp = '|', .text = "|" } }); + try std.testing.expect(pane.prompt != .pipe); + try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); +} diff --git a/src/detached/server.zig b/src/detached/server.zig index b75fa5ad..9b23f8d6 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -9,6 +9,7 @@ const wire = @import("wire.zig"); const host_io = @import("../host_io.zig"); const selection_pipe = @import("../selection_pipe.zig"); +const Pipe = @import("../Pipe.zig"); const file_watch = @import("../file_watch.zig"); @@ -411,7 +412,7 @@ pub const Session = struct { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const request = selection_pipe.pipeRequest(s.core, id) orelse return; + const request = Pipe.pipeRequest(s.core, id) orelse return; const job = selection_pipe.Job.copy(s.worker_gpa, request) catch |err| { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return s.core.reportError(s.core.active, "pipe", err); diff --git a/src/fs.zig b/src/fs.zig index 825e63bb..5374c843 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -44,6 +44,7 @@ const source_files = [_]Source{ .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") }, .{ .path = "src/exec.zig", .contents = @embedFile("exec.zig") }, + .{ .path = "src/Pipe.zig", .contents = @embedFile("Pipe.zig") }, .{ .path = "src/File.zig", .contents = @embedFile("File.zig") }, .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, diff --git a/src/gui/gui.zig b/src/gui/gui.zig index f044ca17..ef94d5b3 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -16,6 +16,7 @@ const crt = @import("crt.zig"); const pet = @import("pet.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); +const Pipe = @import("../Pipe.zig"); const c_heap = @import("c_heap"); const ninep_io = @import("../9p_io.zig"); @@ -1703,7 +1704,7 @@ fn spawnPipe( core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = selection_pipe.pipeRequest(core, id) orelse return; + const view = Pipe.pipeRequest(core, id) orelse return; const job = selection_pipe.Job.copy(gpa, view) catch |err| { core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return core.reportError(core.active, "pipe", err); diff --git a/src/macos.zig b/src/macos.zig index 78356810..dc5b79d0 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -18,6 +18,7 @@ const host_io = @import("host_io.zig"); const fonts = @import("fonts.zig"); // the shared fallback preference order const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks +const Pipe = @import("Pipe.zig"); const crash = @import("crash.zig"); pub const panic = std.debug.FullPanic(struct { @@ -2351,7 +2352,7 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = selection_pipe.pipeRequest(st.core, id) orelse return; + const view = Pipe.pipeRequest(st.core, id) orelse return; const job = selection_pipe.Job.copy(st.gpa, view) catch |err| { st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return st.core.reportError(st.core.active, "pipe", err); diff --git a/src/pardes.zig b/src/pardes.zig index d2ac5868..00439a36 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -17,6 +17,7 @@ pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.zig"); +pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; pub const themes = colors.themes; @@ -443,6 +444,7 @@ test { _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("selection_pipe.zig"); + _ = @import("Pipe.zig"); _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); @@ -3745,7 +3747,7 @@ pub const Pardes = struct { } = null, pipe_seq: u32 = 0, - pipe_wait: ?selection_pipe.PendingPipe = null, + pipe_wait: ?Pipe.PendingPipe = null, fs: filesystem.Namespace = .{}, boot: Boot = .shell, @@ -4688,7 +4690,7 @@ pub const Pardes = struct { p.reportError(e.pane, "terminal exited; Del retries close", err); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), - .pipe_resp => |r| selection_pipe.pipeResponse(p, r.id, r.success, r.outputs, r.failure), + .pipe_resp => |r| Pipe.pipeResponse(p, r.id, r.success, r.outputs, r.failure), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| { p.can_ask = true; @@ -5386,7 +5388,7 @@ pub const Pardes = struct { const submit = hit(key, config.search_submit); switch (pane.prompt) { .pipe => |pipe| { - if (submit) selection_pipe.submitPipe(p, p.active); + if (submit) Pipe.submitPipe(p, p.active); pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); }, .save => |at| { @@ -5501,7 +5503,7 @@ pub const Pardes = struct { .pipe => |how| pipe: { const file = pane.file orelse return; if (!panes.Output.fileTraits(file.output).saves) return; - break :pipe selection_pipe.pipeMarker(how); + break :pipe Pipe.pipeMarker(how); }, }; p.seedTail(pane); diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index 90c8e951..e1a42962 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -1,19 +1,10 @@ -//! `|`: pipe every editor selection through one shell command. First the -//! native runner and its boundary-owned values: native shells call `Job.copy` -//! before starting a worker, then `runOne` on that worker, so no subprocess or -//! borrowed core memory reaches the editor/event-loop thread. Then the editor -//! side: the prompt, the request the core emits, and the atomic edit its -//! answer makes. +//! Native runner and boundary-owned values for `|`: pipe every editor +//! selection through one shell command. The core imports only the plain value +//! types below; native shells call `Job.copy` before starting a worker, then +//! `runOne` on that worker. No subprocess or borrowed core memory reaches the +//! editor/event-loop thread. const std = @import("std"); const filesystem = @import("fs.zig"); -const pardes = @import("pardes.zig"); -const edit = @import("edit.zig"); -const modal = @import("modal.zig"); -const panes = @import("panes.zig"); -const config = @import("config.zig"); -const Pane = panes.Pane; -const Key = pardes.Key; -const Pardes = pardes.Pardes; /// A deliberately finite answer. One selection cannot retain more than 1 MiB /// and a multi-selection command cannot retain more than 4 MiB in total. @@ -401,526 +392,3 @@ test "native pipe runner preserves stdin/stdout bytes and reports how it failed" .failed => return error.EarlyStdinCloseShouldNotFail, } } - -// ---- the pipe inside the editor: the prompt, the request, the atomic edit ---- - -fn nextPipeEffect(p: *Pardes) ?u32 { - while (p.nextEffect()) |effect| switch (effect) { - .pipe => |request| return request.id, - else => {}, - }; - return null; -} - -/// The tag-tail marker each behaviour arms with. One function so the prompt -/// that is DRAWN and the command that is PARSED can never disagree. -pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { - return switch (how) { - .replace => config.pipe_marker, - .ignore => config.pipe_marker_to, - .insert => config.pipe_marker_insert, - .append => config.pipe_marker_append, - }; -} - -pub const PendingPipe = struct { - id: u32, - pane: usize, - serial: u32, - revision: u32, - command: []u8, - cwd: []u8, - inputs: []Input, - ranges: [Pane.max_selections]modal.Selection, - primary: u8, - explicit: bool, - how: modal.Normal.PipeBehavior, - nranges: u8, - - pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { - gpa.free(wait.command); - gpa.free(wait.cwd); - for (wait.inputs) |input| gpa.free(@constCast(input.bytes)); - gpa.free(wait.inputs); - wait.* = undefined; - } - - fn request(wait: *const PendingPipe) Request { - return .{ - .id = wait.id, - .command = wait.command, - .cwd = wait.cwd, - .inputs = wait.inputs, - }; - } -}; - -pub fn submitPipe(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const f = pane.file orelse return; - if (!panes.Output.fileTraits(f.output).saves) return; - const prompt = switch (pane.prompt) { - .pipe => |pipe| pipe, - else => return, - }; - const tail = pane.tagSlice(); - const armed = tail[@min(prompt.at, tail.len)..]; - const marker = pipeMarker(prompt.how); - if (!std.mem.startsWith(u8, armed, marker)) return; - const command = armed[marker.len..]; - if (command.len == 0) return; - - var ranges: [Pane.max_selections]modal.Selection = undefined; - const got = pane.ranges(f.content, 0, &ranges); - // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. - const ninputs = if (prompt.how.pipes()) got.n else 1; - const inputs = p.gpa.alloc(Input, ninputs) catch return; - var made: usize = 0; - for (ranges[0..ninputs], 0..) |range, i| { - const bytes = if (prompt.how.pipes()) bytes: { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (hi > f.content.len) break; - break :bytes f.content[lo..hi]; - } else ""; - const copy = p.gpa.dupe(u8, bytes) catch break; - inputs[i] = .{ .bytes = copy }; - made += 1; - } - if (made != ninputs) { - for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes)); - p.gpa.free(inputs); - return; - } - const command_copy = p.gpa.dupe(u8, command) catch { - for (inputs) |input| p.gpa.free(@constCast(input.bytes)); - p.gpa.free(inputs); - return; - }; - const cwd_copy = p.gpa.dupe(u8, Pardes.paneDir(pane)) catch { - p.gpa.free(command_copy); - for (inputs) |input| p.gpa.free(@constCast(input.bytes)); - p.gpa.free(inputs); - return; - }; - - p.pipe_seq +%= 1; - if (p.pipe_wait) |*old| old.deinit(p.gpa); - p.pipe_wait = .{ - .id = p.pipe_seq, - .pane = id, - .serial = pane.serial, - .revision = f.revision, - .command = command_copy, - .cwd = cwd_copy, - .inputs = inputs, - .ranges = ranges, - .primary = @intCast(got.pri), - .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, - .how = prompt.how, - .nranges = @intCast(got.n), - }; - p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); -} - -/// Frontends call this while draining a .pipe effect, then immediately -/// copy the borrowed view into a worker-owned selection_pipe.Job. -pub fn pipeRequest(p: *const Pardes, id: u32) ?Request { - if (p.pipe_wait) |*wait| { - if (wait.id == id) return wait.request(); - } - return null; -} - -fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?Failure) void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - defer out.deinit(); - const w = &out.writer; - w.print("| {s}\n\n", .{wait.command}) catch return; - if (failure) |fail| { - if (wait.inputs.len > 1) - w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return; - switch (fail.kind) { - .exit => w.print("exit status {d}\n", .{fail.code}) catch return, - .signal => w.writeAll("killed by a signal\n") catch return, - .timeout => w.print( - "still running after {d} seconds, and stopped\n", - .{command_timeout_seconds}, - ) catch return, - .too_large => w.writeAll("produced more output than a filter may return\n") catch return, - .spawn => w.writeAll("could not be started\n") catch return, - .io => w.writeAll("could not be read\n") catch return, - } - if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; - } else { - // No diagnosis at all: nobody ran it. The detached daemon, the - // browser and the board all leave `pipe` null on purpose. - w.writeAll("this session cannot run filters\n") catch return; - } - const content = out.toOwnedSlice() catch return; - const was = p.active; - panes.Output.openErrors(p, wait.pane, content) catch |err| { - p.gpa.free(content); - // Nowhere to put the report is itself worth one line. - p.reportError(wait.pane, "pipe", err); - return; - }; - if (p.panes[was] != null) p.active = was; -} - -fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { - const range = wait.ranges[i]; - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (hi > content.len) return null; - return switch (wait.how) { - .replace, .ignore => .{ .lo = lo, .hi = hi }, - .insert => .{ .lo = lo, .hi = lo }, - .append => .{ .lo = hi, .hi = hi }, - }; -} - -fn pipeOutput( - wait: *const PendingPipe, - outputs: []const []const u8, - content: []const u8, - i: usize, -) []const u8 { - var out = if (wait.how.pipes()) outputs[i] else outputs[0]; - const range = wait.ranges[i]; - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - const selected = if (hi <= content.len) content[lo..hi] else ""; - if (!std.mem.endsWith(u8, selected, "\n") and std.mem.endsWith(u8, out, "\n")) { - out = out[0 .. out.len - 1]; - if (std.mem.endsWith(u8, out, "\r")) out = out[0 .. out.len - 1]; - } - return out; -} - -pub fn pipeResponse( - p: *Pardes, - id: u32, - success: bool, - outputs: []const []const u8, - failure: ?Failure, -) void { - // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is - // the answer to a question nobody is still asking. - if (p.pipe_wait == null or p.pipe_wait.?.id != id) return; - var wait = p.pipe_wait.?; - p.pipe_wait = null; - defer wait.deinit(p.gpa); - - if (!success or outputs.len != wait.inputs.len) return pipeFailed(p, &wait, failure); - - const pane = p.panes[wait.pane] orelse return; - if (pane.serial != wait.serial) return; - const f = if (pane.file) |*file| file else return; - if (!panes.Output.fileTraits(f.output).saves) return; - if (f.revision != wait.revision) - return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); - - if (wait.how == .ignore) return; - - const n = wait.nranges; - var total_output: usize = 0; - var removed: usize = 0; - var previous_end: usize = 0; - for (0..n) |i| { - const cut = pipeCut(&wait, f.content, i) orelse return; - const output = pipeOutput(&wait, outputs, f.content, i); - if (output.len > max_stdout_bytes) return; - total_output = std.math.add(usize, total_output, output.len) catch return; - if (total_output > max_total_stdout_bytes) return; - if (cut.lo < previous_end) return; - removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return; - previous_end = cut.hi; - } - const kept = f.content.len - removed; - const final_len = std.math.add(usize, kept, total_output) catch return; - const replacement = p.gpa.alloc(u8, final_len) catch return; - - var new_ranges: [Pane.max_selections]modal.Selection = undefined; - var read_at: usize = 0; - var write_at: usize = 0; - for (0..n) |i| { - const range = wait.ranges[i]; - const cut = pipeCut(&wait, f.content, i).?; - const output = pipeOutput(&wait, outputs, f.content, i); - @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]); - write_at += cut.lo - read_at; - const out_start = write_at; - @memcpy(replacement[write_at .. write_at + output.len], output); - write_at += output.len; - const out_end = write_at; - new_ranges[i] = if (range.anchor > range.head) - .{ .anchor = out_end, .head = out_start } - else - .{ .anchor = out_start, .head = out_end }; - read_at = cut.hi; - } - @memcpy(replacement[write_at..], f.content[read_at..]); - - // One async request is one history transaction, even at 64 cursors. - edit.pushUndo(p, pane); - panes.File.setContent(p, f, replacement); - pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); - pane.select = false; - pane.ensureCursorVisible(); -} - -test "selection pipe prompt submits exact request and Escape cancels" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("one\ntwo\n"); - pane.cur_row = 0; - pane.cur_col = 2; - pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; - - p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); - 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.prompt != .pipe); - const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; - try std.testing.expectEqualStrings("tr a-z A-Z", request.command); - try std.testing.expectEqualStrings("/", request.cwd); - try std.testing.expectEqual(@as(usize, 1), request.inputs.len); - try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes); - - const before = pane.file.?.content; - 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.prompt != .pipe); - try std.testing.expectEqualSlices(u8, before, pane.file.?.content); - try std.testing.expect(nextPipeEffect(p) == null); -} - -test "selection pipe replaces all ranges atomically and undo restores them" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("aa bb cc\n"); - const ranges = [_]modal.Selection{ - .{ .anchor = 0, .head = 2 }, - .{ .anchor = 6, .head = 8 }, - }; - pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; - try std.testing.expectEqual(@as(usize, 2), request.inputs.len); - try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); - try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); - - var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); - p.scratch.deinit(); - p.scratch = .init(denied.allocator()); - const outputs: []const []const u8 = &.{ "AA\n", "" }; - p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); - try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); - try std.testing.expectEqual(@as(u8, 1), pane.nsel); - try std.testing.expectEqual(@as(i32, 1), pane.cur_col); - try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); - try std.testing.expect(!denied.has_induced_failure); - - p.update(.{ .key = .{ .cp = 'u' } }); - try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); -} - -test "the four shell behaviours put their output where helix puts it" { - const gpa = std.testing.allocator; - const Case = struct { - key: Key, - outputs: []const []const u8, - want: []const u8, - want_effect: bool = true, - }; - // One selection, `bb`, in `aa bb cc\n` — bytes 3..5. - for ([_]Case{ - // `|` replaces the selection with what the command wrote. - .{ .key = .{ .cp = '|' }, .outputs = &.{"XX"}, .want = "aa XX cc\n" }, - // `!` inserts BEFORE it, and the selection survives to its right. - .{ .key = .{ .cp = '!' }, .outputs = &.{"XX"}, .want = "aa XXbb cc\n" }, - // `A-!` appends AFTER it. - .{ .key = .{ .cp = '!', .alt = true }, .outputs = &.{"XX"}, .want = "aa bbXX cc\n" }, - // `A-|` runs the command and leaves the text completely alone: the - // point is the side effect, not the output. - .{ .key = .{ .cp = '|', .alt = true }, .outputs = &.{"XX"}, .want = "aa bb cc\n" }, - }) |case| { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("aa bb cc\n"); - pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); - - p.update(.{ .key = case.key }); - for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = case.outputs } }); - try std.testing.expectEqualSlices(u8, case.want, pane.file.?.content); - } -} - -test "a command with no stdin runs once and every cursor gets that one answer" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("aa bb cc\n"); - const ranges = [_]modal.Selection{ - .{ .anchor = 0, .head = 2 }, - .{ .anchor = 6, .head = 8 }, - }; - pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - - p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin - for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - const request = pipeRequest(p, id) orelse return error.MissingPipeRequest; - - try std.testing.expectEqual(@as(usize, 1), request.inputs.len); - try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); - - p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); - try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); -} - -test "selection pipe failure and stale completion never mutate the file" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("abc\n"); - pane.cur_col = 2; - pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; - - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - 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.?.history.undo_len); - - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - panes.File.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.?.history.undo_len); -} - -test "a failed filter opens an errors buffer carrying the command's own words" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("abc\n"); - pane.cur_col = 2; - pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; - - p.update(.{ .key = .{ .cp = '|' } }); - for ("trr") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - - // The shape a real runner brings home for `| trr`: nonzero exit, and the - // shell's own sentence about it. - var stderr = "sh: line 1: trr: command not found\n".*; - p.update(.{ .pipe_resp = .{ - .id = id, - .success = false, - .outputs = &.{}, - .failure = .{ .kind = .exit, .code = 127, .stderr = &stderr }, - } }); - - // The text is untouched — a failed filter is not an edit... - try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); - // ...and the cursor did not go anywhere, so `|` again edits the same file. - try std.testing.expectEqual(@as(usize, 0), p.active); - - var found: ?[]const u8 = null; - for (p.panes) |slot| { - const q = slot orelse continue; - const qf = q.file orelse continue; - const o = qf.output orelse continue; - if (std.meta.activeTag(o.from) == .errors) found = qf.content; - } - const report = found orelse return error.NoErrorsBuffer; - try std.testing.expect(std.mem.indexOf(u8, report, "| trr") != null); - try std.testing.expect(std.mem.indexOf(u8, report, "exit status 127") != null); - try std.testing.expect(std.mem.indexOf(u8, report, "command not found") != null); -} - -test "selection pipe rejects a reused pane slot and a superseded request" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - var pane = try p.setTestFile("old\n"); - pane.cur_col = 2; - pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; - - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - pane = try p.setTestFile("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.?.history.undo_len); - - pane.cur_col = 2; - pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - p.update(.{ .key = .{ .cp = '|' } }); - p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } }); - p.update(.{ .key = .{ .cp = Key.enter } }); - const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - try std.testing.expect(first_id != latest_id); - - p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } }); - 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.?.history.undo_len); -} - -test "selection pipe binding is file-normal-only" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("x"); - - pane.file.?.output = .{ .from = .search }; - p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.prompt != .pipe); - pane.file.?.output = null; - pane.mode = .insert; - p.update(.{ .key = .{ .cp = '|', .text = "|" } }); - try std.testing.expect(pane.prompt != .pipe); - try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); -} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index fe968650..24403ba8 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -11,6 +11,7 @@ const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const selection_pipe = @import("../selection_pipe.zig"); +const Pipe = @import("../Pipe.zig"); const ninep_io = @import("../9p_io.zig"); const panel_compositor = @import("panel_compositor.zig"); const detached_client = @import("../detached/client.zig"); @@ -1325,7 +1326,7 @@ const Shell = struct { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } - const view = selection_pipe.pipeRequest(s.core, id) orelse return; + const view = Pipe.pipeRequest(s.core, id) orelse return; const job = selection_pipe.Job.copy(s.gpa, view) catch |err| { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return s.core.reportError(s.core.active, "pipe", err); -- cgit v1.3