summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-03 16:28:12 -0300
committerGabriel Schneider <[email protected]>2026-09-03 16:28:12 -0300
commit8f34d29cb7b480545d25c68a8a6146deefdf0523 (patch)
tree89f6ae799dd43cff78fe58194583b700c313b95c /src/pardes.zig
parent0d0691b7bad87763d2e0ad1a0ae90f5e3591c663 (diff)
downloadpardes-8f34d29cb7b480545d25c68a8a6146deefdf0523.tar.gz
pardes-8f34d29cb7b480545d25c68a8a6146deefdf0523.zip
pipe: helix's other three shell commands, and its newline rule
`|` was the only one of helix's five. The other four differ in exactly two things — whether the selection is stdin, and where the output lands — so they are one action carrying a `PipeBehavior` rather than four code paths: | stdin is the selection, output REPLACES it (had this) A-| stdin is the selection, output discarded shell_pipe_to ! no stdin, output inserted BEFORE each selection shell_insert_output A-! no stdin, output appended AFTER each selection shell_append_output Each arms the same visible tag-tail prompt with its own marker (`|`, `|-`, `!`, `!+`) so the prompt says which one you are in — they take the same command line and do very different things to the buffer. Two helix rules came with them. A behaviour that sends no stdin runs the command ONCE and every cursor gets that one answer (helix's `shell_output` cache): ten cursors and `date` give ten identical stamps rather than ten forks racing to produce one. And a command that put a trailing newline on a selection which did not have one has it taken back off — that is what keeps a one-line `| tr a-z A-Z` from becoming two lines. The existing multi-range test moved with that rule and now pins it deliberately. In all three writing behaviours the OUTPUT is what ends up selected, keeping the original range's direction, so an operator can follow straight on from what the command just produced. `$` (`shell_keep_pipe` — drop the selections whose command exited nonzero) is still missing: it needs a per-selection verdict and the runner's answer is atomic. Noted in docs/helix-keys.md beside the `$` divergence already there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig209
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();
}