summaryrefslogtreecommitdiff
path: root/src/diff.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 15:39:27 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commita898c0cce8377150417e7d8dda8f952ffdf241ab (patch)
tree2197f55f9c58c80c5608b84f5510141ba6771cba /src/diff.zig
parentc04df904f2fa686cbe5ce0c078a96260af7b08bd (diff)
downloadpardes-a898c0cce8377150417e7d8dda8f952ffdf241ab.tar.gz
pardes-a898c0cce8377150417e7d8dda8f952ffdf241ab.zip
A right click on a diff line opens the place it names: its file, the hunk's new line, the line itself
In a diff pane (a .diff/.patch file, or a command pane's diff output), the look's own expansion, which its hover shows, takes a whole line for a `diff --git`, `---`, `+++` or `@@` line wherever the pointer is on it, markers included, and for a hunk line when the pointer is on its `+`, `-` or space. Then the look opens the file (a/ b/ dropped, timestamps cut) at the line from the hunk header and the lines above it: `@@` the hunk's first new line, context and added lines their own, a removed line the new line now where it was. `diff --git` and `+++` open the new file; `---` the old one, unless the `+++` under it names another. On a hunk line's code the expansion is its words, the prefix never one of them, and looks as ever. Other panes are untouched. Paths resolve in the repository (walking up to `.git` or `.jj` from the diff's directory, or a command pane's), then that directory. A file not here opens nothing and says `Look: b/x: no such file here`, or `deleted by this diff`. A 9P look writing a whole line of the diff does the same. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/diff.zig')
-rw-r--r--src/diff.zig177
1 files changed, 169 insertions, 8 deletions
diff --git a/src/diff.zig b/src/diff.zig
index 98942469..d129c222 100644
--- a/src/diff.zig
+++ b/src/diff.zig
@@ -1,7 +1,7 @@
//! A unified diff (`git diff`, `diff -u`), read a line at a time: which
//! file each section is about, what each line is, and where a hunk line
//! stands in the old and the new file. The syntax painter (syntax.zig
-//! highlightDiff) walks it.
+//! highlightDiff) and a look on a diff line (look.zig) both walk it.
//!
//! A hunk's extent comes from the counts in its `@@` header, as patch
//! reads it, so a removed line that itself starts `--` (`--- x`) is not
@@ -243,6 +243,85 @@ pub fn looksLikeDiff(lines: []const []const u8) bool {
return false;
}
+/// A look on one line of a diff: the file and line it names.
+pub const Target = struct {
+ /// The line's own kind.
+ kind: Kind,
+ /// As written in the diff, `a/`/`b/` kept.
+ path: []const u8,
+ /// From 1; 0 for the file alone (a `---`/`+++` line).
+ line: usize,
+ /// The section deletes the file, and this is its old side.
+ old_side: bool = false,
+};
+
+/// What a look at `lines[row]` opens, or null for a line that is no part
+/// of a file section (a commit message, `index ...`). The whole line is
+/// the look's: a click on a `+++`, `---` or `@@` marker is one on its path
+/// or its numbers.
+///
+/// `diff --git a/x b/y` opens y, and `+++ y` opens y. `--- x` opens x,
+/// unless the `+++` under it names another file (a rename, a new file,
+/// `diff -u old/x new/x`): then that one, the old name being the one
+/// likely gone.
+///
+/// A removed line opens the new file at the line now standing where it
+/// was: the new line after it, or the hunk's last new line when it went
+/// from the end of the file. In a section that deletes its file there is
+/// no new file, so a removed or context line opens the old one at its old
+/// line (it is there while the diff is not applied).
+pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Target {
+ if (row >= lines.len) return null;
+ var w: Walk = .{ .continued_rows = continued_rows };
+ var r = anchorRow(lines, row);
+ var got: Line = .{ .kind = .other };
+ while (r <= row) : (r += 1) got = w.step(lines[r]);
+ const line = std.mem.trimEnd(u8, lines[row], "\r");
+ switch (got.kind) {
+ .other, .meta, .no_newline => {
+ // `diff --git a/x b/y`: the file, as its `+++` would.
+ if (got.kind == .meta and std.mem.startsWith(u8, line, "diff ") and w.git_path.len > 0)
+ return .{ .kind = got.kind, .path = w.git_path, .line = 0 };
+ return null;
+ },
+ .old_path, .new_path => {
+ const own = cutPath(line[4..]);
+ // `---` names the old file, but the new one on the `+++` under
+ // it wins when they differ: a new file (`--- /dev/null`), a
+ // rename, `diff -u old/x new/x`. The old name is the one
+ // likely gone.
+ if (got.kind == .old_path and row + 1 < lines.len and std.mem.startsWith(u8, lines[row + 1], "+++ ")) {
+ const new = cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]);
+ if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, stripSide(new), stripSide(own))))
+ return .{ .kind = got.kind, .path = new, .line = 0 };
+ }
+ if (!isDevNull(own)) return .{ .kind = got.kind, .path = own, .line = 0, .old_side = got.kind == .old_path and w.deleted() };
+ if (w.path().len == 0) return null;
+ return .{ .kind = got.kind, .path = w.path(), .line = 0, .old_side = w.deleted() };
+ },
+ .hunk => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.new) };
+ },
+ .context, .added, .removed => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ var at = got.new;
+ // Gone from the end of the file: nothing stands after it.
+ if (got.kind == .removed and w.new_count > 0 and at >= w.new_start + w.new_count)
+ at = w.new_start + w.new_count - 1;
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, at) };
+ },
+ }
+}
+
+/// `a/x` and `b/x` as git writes them: the name in the repository.
+pub fn stripSide(path: []const u8) []const u8 {
+ if (path.len > 2 and (path[0] == 'a' or path[0] == 'b') and path[1] == '/') return path[2..];
+ return path;
+}
+
// ---- tests ----
const testing = std.testing;
@@ -295,6 +374,52 @@ test "diff a walk follows the hunk counts, so a removed `--` line is no header"
}, &kinds);
}
+test "diff a look on each kind of line names the new file and its line" {
+ const lines = splitLines(git_diff);
+ const expect = struct {
+ fn at(ls: []const []const u8, row: usize, path: []const u8, line: usize) !void {
+ const t = targetAt(ls, row, false) orelse return error.NoTarget;
+ try testing.expectEqualStrings(path, t.path);
+ try testing.expectEqual(line, t.line);
+ }
+ };
+ try expect.at(&lines, 0, "b/src/a.zig", 0); // diff --git
+ try testing.expect(targetAt(&lines, 1, false) == null); // index
+ try expect.at(&lines, 2, "a/src/a.zig", 0); // ---
+ try expect.at(&lines, 3, "b/src/a.zig", 0); // +++
+ try expect.at(&lines, 4, "b/src/a.zig", 10); // @@
+ try expect.at(&lines, 5, "b/src/a.zig", 10); // context
+ try expect.at(&lines, 6, "b/src/a.zig", 11); // removed: the line now there
+ try expect.at(&lines, 7, "b/src/a.zig", 11); // added
+ try expect.at(&lines, 8, "b/src/a.zig", 12);
+ try expect.at(&lines, 9, "b/src/a.zig", 13);
+ // `--- x` is a removed line, gone from past the hunk's new lines.
+ try expect.at(&lines, 10, "b/src/a.zig", 13);
+ try expect.at(&lines, 11, "b/src/a.zig", 41);
+ try expect.at(&lines, 12, "b/src/a.zig", 41);
+ try expect.at(&lines, 13, "b/src/a.zig", 42);
+ try expect.at(&lines, 14, "b/src/a.zig", 42);
+ // A removed line of a deleted file: its old line in the old file.
+ const gone = targetAt(&lines, 21, false).?;
+ try testing.expectEqualStrings("a/old.py", gone.path);
+ try testing.expectEqual(@as(usize, 2), gone.line);
+ try testing.expect(gone.old_side);
+ try testing.expect(targetAt(&lines, 1, false) == null);
+}
+
+test "diff a removed line at the end of a file opens the hunk's last new line" {
+ const lines = splitLines(
+ \\--- a/f.c
+ \\+++ b/f.c
+ \\@@ -1,3 +1,2 @@
+ \\ one
+ \\ two
+ \\-three
+ \\
+ );
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, false).?.line);
+}
+
test "diff plain diff -u output: no a/ b/, timestamps after the names" {
const lines = splitLines("--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++
"+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++
@@ -303,10 +428,45 @@ test "diff plain diff -u output: no a/ b/, timestamps after the names" {
"+const a = 2;\n" ++
"+const b = 3;\n");
try testing.expect(looksLikeDiff(&lines));
- var w: Walk = .{};
- for (lines) |line| _ = w.step(line);
- try testing.expectEqualStrings("new/x.zig", w.path());
- try testing.expectEqualStrings("old/x.zig", w.old_path);
+ const t = targetAt(&lines, 5, false).?;
+ try testing.expectEqualStrings("new/x.zig", t.path);
+ try testing.expectEqual(@as(usize, 4), t.line);
+ // `--- old/x.zig` with `+++ new/x.zig` under it: the new file.
+ try testing.expectEqualStrings("new/x.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("new/x.zig", stripSide("new/x.zig"));
+ try testing.expectEqualStrings("x.zig", stripSide("b/x.zig"));
+}
+
+test "diff a --- line opens the new name when the +++ under it names another" {
+ const lines = splitLines(
+ \\diff --git a/old name.zig b/new name.zig
+ \\similarity index 90%
+ \\rename from old name.zig
+ \\rename to new name.zig
+ \\--- a/old name.zig
+ \\+++ b/new name.zig
+ \\@@ -1 +1 @@
+ \\-a
+ \\+b
+ \\
+ );
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 4, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 5, false).?.path);
+ try testing.expect(targetAt(&lines, 2, false) == null);
+}
+
+test "diff a new file's --- /dev/null opens the file it makes" {
+ const lines = splitLines(
+ \\--- /dev/null
+ \\+++ b/made.zig
+ \\@@ -0,0 +1 @@
+ \\+const x = 1;
+ \\
+ );
+ try testing.expectEqualStrings("b/made.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 2, false).?.line);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 3, false).?.line);
}
test "diff output is told from other text by its first rows" {
@@ -330,8 +490,9 @@ test "diff hunk headers: counts left out are one, a broken one is none" {
test "diff a terminal's wrapped row stays in its line" {
const lines = [_][]const u8{ "--- a/x.c", "+++ b/x.c", "@@ -1,2 +1,2 @@", "+int a = 1; /* a long", "comment */", " int b;", "-int c;" };
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, true).?.line);
var w: Walk = .{ .continued_rows = true };
- var kinds: [lines.len]Kind = undefined;
- for (lines, 0..) |line, k| kinds[k] = w.step(line).kind;
- try testing.expectEqualSlices(Kind, &.{ .old_path, .new_path, .hunk, .added, .added, .context, .removed }, &kinds);
+ var last: Kind = .other;
+ for (lines[0..5]) |line| last = w.step(line).kind;
+ try testing.expectEqual(Kind.added, last);
}