diff options
| -rw-r--r-- | src/diff.zig | 47 | ||||
| -rw-r--r-- | test/fs.py | 19 |
2 files changed, 65 insertions, 1 deletions
diff --git a/src/diff.zig b/src/diff.zig index d129c222..9ab1bb23 100644 --- a/src/diff.zig +++ b/src/diff.zig @@ -161,12 +161,41 @@ fn isDevNull(path: []const u8) bool { /// A `---`/`+++` path with what follows it cut: `diff -u`'s tab and /// timestamp, and git's quotes round a name it had to escape. fn cutPath(rest: []const u8) []const u8 { - var name = rest[0 .. std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len]; + var name = rest[0 .. std.mem.indexOfScalar(u8, rest, '\t') orelse stampAt(rest) orelse rest.len]; name = std.mem.trimEnd(u8, name, " \r"); if (name.len >= 2 and name[0] == '"' and name[name.len - 1] == '"') name = name[1 .. name.len - 1]; return name; } +/// Where `diff -u`'s timestamp starts when a terminal turned the tab before +/// it into blanks (a command pane's output): the start of the blanks that +/// run up to one. A tab may become a single blank, so one is enough; the +/// stamp itself is strict, so a name with blanks in it is not cut. +fn stampAt(rest: []const u8) ?usize { + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, rest, i, ' ')) |blank| { + var end = blank; + while (end < rest.len and rest[end] == ' ') end += 1; + if (isStamp(rest[end..])) return blank; + i = end; + } + return null; +} + +/// `2026-09-30 10:00:00...` (GNU diff) or `Wed Sep 30 10:00:00 2026` (ctime). +fn isStamp(t: []const u8) bool { + const d = std.ascii.isDigit; + if (t.len >= 19 and d(t[0]) and d(t[1]) and d(t[2]) and d(t[3]) and t[4] == '-' and d(t[5]) and d(t[6]) and t[7] == '-' and + d(t[8]) and d(t[9]) and t[10] == ' ' and d(t[11]) and d(t[12]) and t[13] == ':' and d(t[14]) and d(t[15]) and t[16] == ':' and d(t[17]) and d(t[18])) + return true; + if (t.len < 24 or t[3] != ' ' or t[7] != ' ') return false; + for ([_][]const u8{ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" }) |day| if (std.mem.eql(u8, t[0..3], day)) { + const clock = t[11..19]; + return (d(t[8]) or t[8] == ' ') and d(t[9]) and t[10] == ' ' and d(clock[0]) and d(clock[1]) and clock[2] == ':' and d(clock[3]) and d(clock[4]) and clock[5] == ':'; + }; + return false; +} + /// `diff --git a/x b/y`: the b side. A name with a space in it is split at /// the last ` b/`, which is right unless the old name has one too. fn gitPath(line: []const u8) []const u8 { @@ -437,6 +466,22 @@ test "diff plain diff -u output: no a/ b/, timestamps after the names" { try testing.expectEqualStrings("x.zig", stripSide("b/x.zig")); } +test "diff -u in a command pane, its tabs turned to blanks by the terminal, names its files without the stamps" { + // As a terminal shows `diff -u old/x.zig new/x.zig`: each tab gone to + // the blanks up to the next tab stop, one of them a single blank. + const lines = splitLines("--- old/x.zig 2026-09-30 10:00:00.000000000 +0000\n" ++ + "+++ new/a b.zig 2026-09-30 11:00:00.000000000 +0000\n" ++ + "@@ -3 +3,2 @@\n" ++ + "-const a = 1;\n" ++ + "+const a = 2;\n" ++ + "+const b = 3;\n"); + try testing.expect(looksLikeDiff(&lines)); + try testing.expectEqualStrings("new/a b.zig", targetAt(&lines, 5, false).?.path); + try testing.expectEqualStrings("x.zig", cutPath("x.zig Wed Sep 30 10:00:00 2026")); + // A name with blanks and no stamp after them is whole. + try testing.expectEqualStrings("my file 2026 notes.txt", cutPath("my file 2026 notes.txt")); +} + 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 @@ -381,6 +381,25 @@ def discovery(binary, embedded=False): # Through a mount both read as EINVAL; the log says which and why. assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log') client.remove(f'/pane/{scratch}') + # Real `diff -u` in a command pane: the terminal turns the tab + # before each timestamp into blanks, and a look at a changed line + # still opens the new file at it. + (root / 'diff-old.txt').write_text('one\ntwo\nthree\n') + (root / 'diff-new.txt').write_text('one\nTWO\nthree\n') + seen = client.read('/log').decode().count('\nexit ') + client.write('/exec', b'diff -u diff-old.txt diff-new.txt\n') + deadline = time.monotonic() + 10 + while client.read('/log').decode().count('\nexit ') <= seen: + assert time.monotonic() < deadline, 'diff -u never ended' + time.sleep(.05) + ran = [row.split()[0] for row in client.read('/index').decode().splitlines() if row.split()[1] == 'term'] + shown = client.read(f'/pane/{ran[-1]}/body').decode() + assert '+++ diff-new.txt' in shown and '\t' not in shown, shown + client.write(f'/pane/{ran[-1]}/look', b'+TWO\n') + opened = [row for row in client.read('/index').decode().splitlines() if row.split()[3] == f'{root}/diff-new.txt'] + assert len(opened) == 1, client.read('/index') + client.remove(f'/pane/{opened[0].split()[0]}') + client.remove(f'/pane/{ran[-1]}') # A Save makes the directories its file goes in, whatever names # it: the pane's own name or Save's path. # `~` is home (this session's HOME, the test's root) wherever a |
