diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 209 |
1 files changed, 183 insertions, 26 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index edff0ec7..b87a5d6a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -747,9 +747,13 @@ test "selection pipe replaces all ranges atomically and undo restores them" { try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); + // `AA\n` for a selection that was just `aa`: helix takes a trailing newline + // back off when the input did not have one, which is what keeps a one-line + // `| tr a-z A-Z` from becoming two lines. The empty output for `cc` deletes + // it outright, which is a filter's ordinary right. 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.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); @@ -757,6 +761,72 @@ test "selection pipe replaces all ranges atomically and undo restores them" { 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.hxOpenFileContent("aa bb cc\n"); + const pl = try p.paneCursorLines(pane); + Pardes.setPaneRanges(pane, pl, 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.hxOpenFileContent("aa bb cc\n"); + const pl = try p.paneCursorLines(pane); + const ranges = [_]modal.HxRange{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 6, .head = 8 }, + }; + Pardes.setPaneRanges(pane, pl, 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 = p.pipeRequest(id) orelse return error.MissingPipeRequest; + + // ONE invocation for two cursors, with nothing on its stdin — helix's + // `shell_output` cache. Two invocations of `date` could disagree, and ten + // cursors would mean ten forks to produce one answer. + 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.?.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 }); @@ -4484,6 +4554,10 @@ pub const Pane = struct { /// 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, + /// WHICH of helix's shell commands armed `prompt.pipe`. Beside the prompt + /// rather than inside it because `promptAt` reads all three prompt kinds + /// through one prong, and a payload here would have split that. + pipe_how: normal_input.PipeBehavior = .replace, search_pane: ?usize = null, search_row: ?usize = null, /// Where n/N last stood in this pane, or null when the walk has not been @@ -6047,6 +6121,17 @@ pub const Options = struct { rows: u16 = 24, }; +/// 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. +fn pipeMarker(how: normal_input.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, + }; +} + const PendingPipe = struct { id: u32, pane: usize, @@ -6058,6 +6143,13 @@ const PendingPipe = struct { ranges: [MAX_SELS]modal.HxRange, primary: u8, explicit: bool, + how: normal_input.PipeBehavior, + /// How many ranges the selection had. Not always `inputs.len`: `!` and + /// `A-!` send NO stdin and run the command ONCE, so they submit a single + /// empty input and paste that one answer at every range — helix's + /// `shell_output` cache, which is why `date` at ten cursors gives ten + /// identical stamps rather than ten different ones. + nranges: u8, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); @@ -10323,7 +10415,7 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .pipe_selection => return p.startPipe(pane), + .pipe_selection => |how| return p.startPipe(pane, how), .search => return p.startSearch(pane, config.search_marker), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, @@ -10333,7 +10425,7 @@ pub const Pardes = struct { // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- - fn startPipe(p: *Pardes, pane: *Pane) void { + fn startPipe(p: *Pardes, pane: *Pane, how: normal_input.PipeBehavior) void { // A buffer that IS a file, or the scratch that becomes one: a filter // rewrites bytes the pane owns. Never a terminal (shell output cannot // be rewritten), never a rendering that its next refill would discard. @@ -10342,8 +10434,9 @@ pub const Pardes = struct { p.seedTail(pane); if (!pane.tag_init) return; const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(config.pipe_marker)) return; + if (!pane.appendTag(pipeMarker(how))) return; pane.prompt = .{ .pipe = prompt_at }; + pane.pipe_how = how; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; @@ -10360,23 +10453,32 @@ pub const Pardes = struct { if (!output_pane.fileTraits(f.output).saves) return; 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..]; + const marker = pipeMarker(pane.pipe_how); + if (!std.mem.startsWith(u8, armed, marker)) return; + const command = armed[marker.len..]; if (command.len == 0) return; var ranges: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, f.content, 0, &ranges); - const inputs = p.gpa.alloc(selection_pipe.Input, got.n) catch return; + // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. + const ninputs = if (pane.pipe_how.pipes()) got.n else 1; + const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; - for (ranges[0..got.n], 0..) |range, i| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (hi > f.content.len) break; - const copy = p.gpa.dupe(u8, f.content[lo..hi]) catch break; + for (ranges[0..ninputs], 0..) |range, i| { + // A behaviour that sends no stdin still submits one input, empty: + // the runner's contract is one invocation per input, and `!` wants + // exactly one invocation with nothing on its stdin. + const bytes = if (pane.pipe_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 != got.n) { + if (made != ninputs) { for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; @@ -10406,6 +10508,8 @@ pub const Pardes = struct { .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, + .how = pane.pipe_how, + .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); } @@ -10468,6 +10572,48 @@ pub const Pardes = struct { if (p.panes[was] != null) p.active = was; } + /// WHERE range `i`'s output goes, as a span of the pre-edit content to be + /// replaced by it. The three writing behaviours differ in exactly this and + /// nothing else: `|` swaps the selection out, `!` opens a hole at its + /// start, `A-!` at its end. Null when the range no longer fits the text. + 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 }, + }; + } + + /// WHAT range `i` receives. + /// + /// Two helix rules live here. A behaviour that sends no stdin ran the + /// command ONCE, so every range gets `outputs[0]` — ten cursors and `date` + /// give ten identical stamps rather than ten racing ones. And a command + /// that added a trailing newline to a selection which did not have one has + /// it taken back off, which is what keeps `| tr a-z A-Z` on one line from + /// splitting it in two. + 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; + } + fn pipeResponse( p: *Pardes, id: u32, @@ -10494,18 +10640,24 @@ pub const Pardes = struct { if (f.revision != wait.revision) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); + // `A-|` ran the command FOR ITS EFFECT. There is nothing to splice and + // the selection is left exactly where it was, which is the whole + // difference between it and `|`. + if (wait.how == .ignore) return; + + const n = wait.nranges; var total_output: usize = 0; var removed: usize = 0; var previous_end: usize = 0; - for (outputs, wait.ranges[0..outputs.len]) |output, range| { + 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; - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (lo < previous_end or hi > f.content.len) return; - removed = std.math.add(usize, removed, hi - lo) catch return; - previous_end = hi; + 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; @@ -10514,20 +10666,25 @@ pub const Pardes = struct { var new_ranges: [MAX_SELS]modal.HxRange = undefined; var read_at: usize = 0; var write_at: usize = 0; - for (outputs, wait.ranges[0..outputs.len], 0..) |output, range, i| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - @memcpy(replacement[write_at .. write_at + (lo - read_at)], f.content[read_at..lo]); - write_at += lo - read_at; + 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; + // THE OUTPUT IS WHAT ENDS UP SELECTED, for all three behaviours + // that write — helix `shell()` builds its new range around the + // inserted text, keeping the original's direction, so a `!` can be + // followed straight by another operator on what it just produced. new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else .{ .anchor = out_start, .head = out_end }; - read_at = hi; + read_at = cut.hi; } @memcpy(replacement[write_at..], f.content[read_at..]); @@ -10535,7 +10692,7 @@ pub const Pardes = struct { p.pushUndo(pane); file_pane.setContent(p, f, replacement); const pl = p.paneCursorLines(pane) catch return; - setPaneRanges(pane, pl, f.content, new_ranges[0..outputs.len], &.{}, wait.primary, wait.explicit); + setPaneRanges(pane, pl, f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } |
