summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
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();
}