diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 15:39:20 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | c04df904f2fa686cbe5ce0c078a96260af7b08bd (patch) | |
| tree | ebb892b5b2f4c91505c241d1408fea1a1904a883 /src | |
| parent | b9eefda75ab9a63fc510de781d02b92fc04ad31d (diff) | |
| download | pardes-c04df904f2fa686cbe5ce0c078a96260af7b08bd.tar.gz pardes-c04df904f2fa686cbe5ce0c078a96260af7b08bd.zip | |
A diff reads as the code it changes: hunks in their file's language, added and removed rows tinted
A .diff or .patch pane, and a command pane whose output is a diff (`git
diff`, `git show`, `diff -u`; told by a `diff --git` line, or `---`/`+++`
with a `@@` under them, in its first rows), colour each hunk's code in the
language of the file its section names (`+++ b/<path>`, `--- a/<path>` for
a deleted file), with tree-sitter, as highlightLocations does for results.
src/diff.zig walks a unified diff by its `@@` counts, so a removed line
that reads `--- x` is no header. Each side of a hunk is parsed as one text
(context and removed lines the old side, context and added lines the new),
so a string or comment across lines colours as it does in the file; a side
no line takes its colours from is not parsed. A section's hunks share a
parse, in pieces of about 40 lines (a hunk past 80 is cut), only those in
view, and each piece's colours are kept by its bytes, so scrolling back and
a terminal's every repaint parse nothing again.
Added and removed lines carry a flag in their style byte; the painter tints
their rows to the pane's edge, a little way from the page toward the
theme's ANSI green or red, and draws the prefix in that hue pushed to read
on it. A file in no known language keeps the old line colours.
A command pane is read once its command has finished: its rows are copied
once (File.DiffOutput, dropped when the pane runs again), so no frame dumps
the scrollback, and painted over what git printed. A running command is
shown as it prints.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 8 | ||||
| -rw-r--r-- | src/File.zig | 232 | ||||
| -rw-r--r-- | src/body_layer.zig | 1 | ||||
| -rw-r--r-- | src/diff.zig | 337 | ||||
| -rw-r--r-- | src/exec.zig | 1 | ||||
| -rw-r--r-- | src/pardes.zig | 4 | ||||
| -rw-r--r-- | src/syntax.zig | 499 |
7 files changed, 1044 insertions, 38 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 25740d16..5f53ff8a 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- A diff reads as the code it changes: a `.diff` or `.patch` file, and a + command's output that is a diff (`git diff`), colour each hunk in its + file's language, its old and new side each parsed whole, and tint added + and removed rows to the pane's edge with their `+`/`-` in the tint's hue. + Only the hunks in view are parsed, once each. + ## 0.0.8 - `~` and `~/` mean the home directory wherever a path is taken: look diff --git a/src/File.zig b/src/File.zig index 84cec879..2046a7cd 100644 --- a/src/File.zig +++ b/src/File.zig @@ -10,10 +10,12 @@ const Surface = pardes.Surface; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); +const diff = @import("diff.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); +const Colors = @import("colors.zig"); const Pane = panes.Pane; const Output = panes.Output; const Mini = panes.mini; @@ -1301,6 +1303,59 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, const SynStyle = struct { fg: [3]u8, bold: bool }; +/// How added and removed diff lines are told apart in every theme: a row +/// ground a little way from the page toward green or red (the theme's own +/// ANSI green and red where it has a palette), and the prefix column in +/// that hue, pushed toward the ink until it reads on that ground. The +/// syntax colours are drawn over the ground unchanged. +pub const DiffTints = struct { + added_bg: [3]u8, + removed_bg: [3]u8, + added_fg: [3]u8, + removed_fg: [3]u8, + + pub fn of(p: *Pardes) DiffTints { + const th = p.theme(); + const page = th.bg orelse th.tag_bg; + const ink = th.fg orelse th.tag_fg; + const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 }; + const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e }; + const added_bg = Colors.mixOklab(page, green, 0.18); + const removed_bg = Colors.mixOklab(page, red, 0.18); + return .{ + .added_bg = added_bg, + .removed_bg = removed_bg, + .added_fg = legible(green, ink, added_bg), + .removed_fg = legible(red, ink, removed_bg), + }; + } + + fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 { + var fg = hue; + var step: usize = 0; + while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4); + return fg; + } + + /// The row ground and prefix ink for a line's style byte, if it is an + /// added or removed line. + pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } { + if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg }; + if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg }; + return null; + } +}; + +/// A diff row's tint: its ground from `x` to the pane's edge. +fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void { + var col = x; + while (col < end) : (col += 1) { + const cell = s.at(col, y); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + } +} + fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { return switch (sy) { .none => null, @@ -1320,6 +1375,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); + const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null; // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const total = nlines(p.gpa, f); @@ -1352,6 +1408,12 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; const decoration = Output.decorateRow(f.output, line, metadata); const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome(); + // An added or removed line's row, every wrapped row of it, is tinted. + const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len) + t.forStyle(highlights[base - highlight_start]) + else + null else null; + if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -1361,7 +1423,11 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. if (idx >= highlight_start) { const hidx = idx - highlight_start; if (hidx < highlights.len) { - if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| { + if (if (tint != null and hs + c == 0) + SynStyle{ .fg = tint.?.fg, .bold = true } + else + synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |ss| + { var fill: usize = 0; while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); @@ -1390,6 +1456,170 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. } } +/// What finished command panes printed, kept while it cannot change: a +/// copy of the terminal's rows, and whether they are a diff. Taken once +/// when a finished pane is first drawn, so neither a frame nor a look dumps +/// the whole scrollback again; a still running command is shown as it +/// printed. Keyed by the pane and its width (a resize rewraps every row); +/// a rerun in the pane drops it (exec.runCommand). +pub const DiffOutput = struct { + const Entry = struct { + serial: u32, + cols: u16, + is_diff: bool, + /// The rows joined by '\n'; `rows` are slices of it. + text: []u8, + rows: [][]const u8, + used: u64, + }; + slots: [4]?Entry = @splat(null), + clock: u64 = 0, + + pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void { + for (&o.slots) |*slot| { + if (slot.*) |e| { + gpa.free(e.text); + gpa.free(e.rows); + } + slot.* = null; + } + } + + /// A pane's command runs again: what it printed before is gone. + pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void { + for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) { + gpa.free(e.text); + gpa.free(e.rows); + slot.* = null; + }; + } + + /// A finished command pane's rows when they are a diff, else null. + pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 { + if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null; + o.clock += 1; + for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) { + e.used = o.clock; + return if (e.is_diff) e.rows else null; + }; + const shown = terminal.shellRows(p, pane) catch return null; + const is_diff = diff.looksLikeDiff(shown); + var total: usize = 0; + if (is_diff) for (shown) |row| { + total += row.len + 1; + }; + const text = p.gpa.alloc(u8, total) catch return null; + const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch { + p.gpa.free(text); + return null; + }; + var at: usize = 0; + for (rows, 0..) |*row, i| { + @memcpy(text[at..][0..shown[i].len], shown[i]); + row.* = text[at..][0..shown[i].len]; + at += shown[i].len; + text[at] = '\n'; + at += 1; + } + var oldest: usize = 0; + for (&o.slots, 0..) |*slot, i| { + const e = slot.* orelse { + oldest = i; + break; + }; + if (o.slots[oldest]) |old| if (e.used < old.used) { + oldest = i; + }; + } + if (o.slots[oldest]) |old| { + p.gpa.free(old.text); + p.gpa.free(old.rows); + } + o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock }; + return if (is_diff) rows else null; + } +}; + +/// A command pane whose output is a diff (`git diff`, `diff -u`): its +/// rows coloured as a .diff file's are, over what the program printed. +/// The rows are the terminal's own, so a line it wrapped goes on in the +/// row below (syntax.highlightDiffRows). +pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + if (pane.command == null or !p.settings.colors) return; + const tz = tracy.zone(@src(), "diffRecolor"); + defer tz.end(); + const rows = p.diff_output.lines(p, pane) orelse return; + // The copy's rows are one text, joined as a file's lines are. + const flat_start = @intFromPtr(rows[0].ptr); + const last = rows[rows.len - 1]; + const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start]; + const raw = pane.body.mode == .tty; + const View = struct { row: usize, at: usize }; + const arena = p.scratch.allocator(); + const views = arena.alloc(?View, body_h) catch return; + var lo: usize = std.math.maxInt(usize); + var hi: usize = 0; + var shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |*view, vi| { + view.* = null; + const line = shown.next() orelse continue; + const vr: i32 = @intCast(vi); + const w = pane.wrapAt(vr); + const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line; + if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue; + const row: usize = @intCast(row_i); + const from: usize = if (raw) 0 else @intCast(@max(0, w.at)); + if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue; + view.* = .{ .row = row, .at = from }; + const start = @intFromPtr(rows[row].ptr) - flat_start; + lo = @min(lo, start); + hi = @max(hi, start + rows[row].len); + } + if (lo > hi) return; + const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return; + const tints = DiffTints.of(p); + const ink = p.theme().fg orelse p.theme().tag_fg; + const body_y = p.bodyTop(pane, r); + shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |maybe, vr| { + const line = shown.next() orelse break; + const view = maybe orelse continue; + const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo; + const tint = if (base < styles.len) tints.forStyle(styles[base]) else null; + if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg); + var c: usize = 0; + var screen_c: usize = 0; + while (c < line.len and screen_c < tw) { + const grapheme_end = modal.nextGrapheme(line, c); + if (grapheme_end <= c) break; + const cells = graphemeDisplayWidth(line[c..grapheme_end]); + const style = styles[base + view.at + c]; + const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-'); + // On an added or removed line, what the program coloured red or + // green is the tint's to say now: its code takes the page's ink. + const paint: ?SynStyle = if (tint != null and prefix) + .{ .fg = tint.?.fg, .bold = true } + else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss| + ss + else if (tint != null) + .{ .fg = ink, .bold = false } + else + null; + if (paint) |ss| { + var fill: usize = 0; + while (fill < cells and screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr))); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } + screen_c += cells; + c = grapheme_end; + } + } +} + pub fn drawWrapMarkers( p: *Pardes, s: *Surface, diff --git a/src/body_layer.zig b/src/body_layer.zig index cb26c1f2..8e7cd030 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -292,6 +292,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); + panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); diff --git a/src/diff.zig b/src/diff.zig new file mode 100644 index 00000000..98942469 --- /dev/null +++ b/src/diff.zig @@ -0,0 +1,337 @@ +//! 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. +//! +//! 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 +//! taken for a file header. +const std = @import("std"); + +pub const Kind = enum { + /// Anything outside a file header and a hunk: a commit message, + /// `Only in ...`, a mail's headers. + other, + /// `diff ...`, `index ...`, `new file mode ...`, `rename from ...`, + /// `Binary files ... differ`: said of the section, naming no line. + meta, + /// `--- <old path>` + old_path, + /// `+++ <new path>` + new_path, + /// `@@ -a,b +c,d @@` + hunk, + context, + added, + removed, + /// `\ No newline at end of file` + no_newline, +}; + +/// One line, walked. +pub const Line = struct { + kind: Kind, + /// Where it is in the old and the new file, from 1. A hunk header has + /// its first lines; a removed line the new line now standing where it + /// was (the one after it); an added line the old line after it. + old: usize = 0, + new: usize = 0, +}; + +pub const Walk = struct { + /// The section's paths, as written, borrowed from the lines walked: + /// `a/` and `b/` kept, timestamps cut. + old_path: []const u8 = "", + new_path: []const u8 = "", + /// `diff --git a/x b/y`'s b side, for a section with no `+++` yet + /// (a mode change, a rename, a binary file). + git_path: []const u8 = "", + old_left: usize = 0, + new_left: usize = 0, + old_at: usize = 0, + new_at: usize = 0, + /// The hunk's header numbers, which a look past its end clamps to. + new_start: usize = 0, + new_count: usize = 0, + /// Rows a terminal wrapped: inside a hunk, a row that starts with no + /// prefix at all continues the line above instead of ending the hunk. + continued_rows: bool = false, + last: Kind = .other, + + pub fn inHunk(w: *const Walk) bool { + return w.old_left > 0 or w.new_left > 0; + } + + /// The file the section is about: the new one, else (deleted) the old + /// one, else what its `diff --git` line names. + pub fn path(w: *const Walk) []const u8 { + if (w.new_path.len > 0 and !isDevNull(w.new_path)) return w.new_path; + if (w.old_path.len > 0 and !isDevNull(w.old_path)) return w.old_path; + return w.git_path; + } + + /// Whether the section deletes its file (`+++ /dev/null`). + pub fn deleted(w: *const Walk) bool { + return isDevNull(w.new_path); + } + + pub fn step(w: *Walk, line_raw: []const u8) Line { + const line = std.mem.trimEnd(u8, line_raw, "\r"); + const got = w.stepInner(line); + w.last = got.kind; + return got; + } + + fn stepInner(w: *Walk, line: []const u8) Line { + if (w.inHunk()) { + // A blank context line, its space stripped by a mailer or an + // editor, is still context. + const c: u8 = if (line.len == 0) ' ' else line[0]; + switch (c) { + ' ' => if (w.old_left > 0 and w.new_left > 0) { + const at: Line = .{ .kind = .context, .old = w.old_at, .new = w.new_at }; + w.old_left -= 1; + w.new_left -= 1; + w.old_at += 1; + w.new_at += 1; + return at; + }, + '-' => if (w.old_left > 0) { + const at: Line = .{ .kind = .removed, .old = w.old_at, .new = w.new_at }; + w.old_left -= 1; + w.old_at += 1; + return at; + }, + '+' => if (w.new_left > 0) { + const at: Line = .{ .kind = .added, .old = w.old_at, .new = w.new_at }; + w.new_left -= 1; + w.new_at += 1; + return at; + }, + '\\' => return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at }, + else => if (w.continued_rows and w.last != .hunk and w.last != .other) + return .{ .kind = w.last, .old = w.old_at, .new = w.new_at }, + } + // Counts run out, or a line no hunk has: the hunk was cut short. + w.old_left = 0; + w.new_left = 0; + } + if (line.len > 0 and line[0] == '\\' and w.last != .other and w.last != .meta) + return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at }; + if (std.mem.startsWith(u8, line, "diff ")) { + w.* = .{ .continued_rows = w.continued_rows }; + w.git_path = gitPath(line); + return .{ .kind = .meta }; + } + if (std.mem.startsWith(u8, line, "--- ")) { + // A second `---` with no `+++` between starts another section + // (plain `diff -u` output has no `diff` line of its own). + w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path }; + w.old_path = cutPath(line[4..]); + return .{ .kind = .old_path }; + } + if (std.mem.startsWith(u8, line, "+++ ")) { + w.new_path = cutPath(line[4..]); + return .{ .kind = .new_path }; + } + if (parseHunk(line)) |h| { + w.old_left = h.old_count; + w.new_left = h.new_count; + w.old_at = h.old_start; + w.new_at = h.new_start; + w.new_start = h.new_start; + w.new_count = h.new_count; + return .{ .kind = .hunk, .old = h.old_start, .new = h.new_start }; + } + for (meta_words) |word| if (std.mem.startsWith(u8, line, word)) return .{ .kind = .meta }; + return .{ .kind = .other }; + } +}; + +const meta_words = [_][]const u8{ + "index ", "new file mode ", "deleted file mode ", "old mode ", "new mode ", + "similarity index ", "rename from ", "rename to ", "copy from ", "copy to ", + "Binary files ", "dissimilarity ", +}; + +fn isDevNull(path: []const u8) bool { + return std.mem.eql(u8, path, "/dev/null"); +} + +/// 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]; + 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; +} + +/// `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 { + if (!std.mem.startsWith(u8, line, "diff --git ")) return ""; + const rest = line["diff --git ".len..]; + const at = std.mem.lastIndexOf(u8, rest, " b/") orelse return ""; + return std.mem.trimEnd(u8, rest[at + 1 ..], " \r"); +} + +pub const HunkHeader = struct { old_start: usize, old_count: usize, new_start: usize, new_count: usize }; + +/// `@@ -a[,b] +c[,d] @@ ...`, a count left out meaning one. +pub fn parseHunk(line: []const u8) ?HunkHeader { + if (!std.mem.startsWith(u8, line, "@@ -")) return null; + var at: usize = "@@ -".len; + const old = range(line, &at) orelse return null; + if (at + 1 >= line.len or line[at] != ' ' or line[at + 1] != '+') return null; + at += 2; + const new = range(line, &at) orelse return null; + if (!std.mem.startsWith(u8, line[at..], " @@")) return null; + return .{ .old_start = old[0], .old_count = old[1], .new_start = new[0], .new_count = new[1] }; +} + +fn range(line: []const u8, at: *usize) ?[2]usize { + const start = number(line, at) orelse return null; + if (at.* < line.len and line[at.*] == ',') { + at.* += 1; + return .{ start, number(line, at) orelse return null }; + } + return .{ start, 1 }; +} + +fn number(line: []const u8, at: *usize) ?usize { + const from = at.*; + var n: usize = 0; + while (at.* < line.len and std.ascii.isDigit(line[at.*])) : (at.* += 1) + n = std.math.add(usize, std.math.mul(usize, n, 10) catch return null, line[at.*] - '0') catch return null; + return if (at.* > from) n else null; +} + +/// Where a walk that ends up at byte `pos` of `content` may safely start: +/// the start of the nearest `diff ` line before it (no hunk line starts +/// with a `d`), else the top. +pub fn anchorBefore(content: []const u8, pos: usize) usize { + var end = @min(pos, content.len); + while (end > 0) { + const nl = std.mem.lastIndexOfScalar(u8, content[0..end], '\n') orelse break; + if (std.mem.startsWith(u8, content[nl + 1 ..], "diff ") and nl + 1 <= pos) return nl + 1; + end = nl; + } + return 0; +} + +/// The same for a list of lines: the nearest `diff ` line at or above `row`. +pub fn anchorRow(lines: []const []const u8, row: usize) usize { + var r = @min(row, lines.len); + while (r > 0) { + r -= 1; + if (std.mem.startsWith(u8, lines[r], "diff ")) return r; + } + return 0; +} + +/// Whether text a command printed is a diff: a `diff --git` line, or a +/// `--- ` line with a `+++ ` line under it, in its first rows (after +/// `git show`'s commit header, say). +pub fn looksLikeDiff(lines: []const []const u8) bool { + const n = @min(lines.len, 200); + for (lines[0..n], 0..) |line, i| { + if (std.mem.startsWith(u8, line, "diff --git ")) return true; + if (std.mem.startsWith(u8, line, "--- ") and i + 1 < lines.len and std.mem.startsWith(u8, lines[i + 1], "+++ ") and + i + 2 < lines.len and parseHunk(lines[i + 2]) != null) return true; + } + return false; +} + +// ---- tests ---- + +const testing = std.testing; + +fn splitLines(comptime text: []const u8) [std.mem.count(u8, text, "\n")][]const u8 { + @setEvalBranchQuota(100_000); + var out: [std.mem.count(u8, text, "\n")][]const u8 = undefined; + var it = std.mem.splitScalar(u8, text, '\n'); + for (&out) |*line| line.* = it.next().?; + return out; +} + +const git_diff = + \\diff --git a/src/a.zig b/src/a.zig + \\index 1111111..2222222 100644 + \\--- a/src/a.zig + \\+++ b/src/a.zig + \\@@ -10,4 +10,4 @@ pub fn main() void { + \\ const a = 1; + \\-const b = 2; + \\+const b = 3; + \\+const c = 4; + \\ const d = 5; + \\--- x + \\@@ -40,2 +41,2 @@ + \\ keep(); + \\-gone(); + \\+added(); + \\diff --git a/old.py b/old.py + \\deleted file mode 100644 + \\--- a/old.py + \\+++ /dev/null + \\@@ -1,2 +0,0 @@ + \\-def f(): + \\- return 1 + \\ +; + +test "diff a walk follows the hunk counts, so a removed `--` line is no header" { + const lines = splitLines(git_diff); + var w: Walk = .{}; + var kinds: [lines.len]Kind = undefined; + for (lines, 0..) |line, i| kinds[i] = w.step(line).kind; + try testing.expectEqualSlices(Kind, &.{ + .meta, .meta, .old_path, .new_path, .hunk, + .context, .removed, .added, .added, .context, + .removed, .hunk, .context, .removed, .added, + .meta, .meta, .old_path, .new_path, .hunk, + .removed, .removed, + }, &kinds); +} + +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" ++ + "@@ -3 +3,2 @@\n" ++ + "-const a = 1;\n" ++ + "+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); +} + +test "diff output is told from other text by its first rows" { + const git = [_][]const u8{ "$ git diff", "diff --git a/x b/x", "index 1..2" }; + try testing.expect(looksLikeDiff(&git)); + const show = [_][]const u8{ "commit abc", "Author: a", "", " msg", "", "diff --git a/x b/x" }; + try testing.expect(looksLikeDiff(&show)); + const prose = [_][]const u8{ "--- a heading", "+++ not a path", "text" }; + try testing.expect(!looksLikeDiff(&prose)); + const nothing = [_][]const u8{ "ls", "a.zig b.zig" }; + try testing.expect(!looksLikeDiff(¬hing)); +} + +test "diff hunk headers: counts left out are one, a broken one is none" { + try testing.expectEqual(HunkHeader{ .old_start = 3, .old_count = 1, .new_start = 4, .new_count = 1 }, parseHunk("@@ -3 +4 @@").?); + try testing.expectEqual(HunkHeader{ .old_start = 1, .old_count = 0, .new_start = 0, .new_count = 0 }, parseHunk("@@ -1,0 +0,0 @@ fn x").?); + try testing.expect(parseHunk("@@ -a +1 @@") == null); + try testing.expect(parseHunk("@@ -1 +1") == null); + try testing.expect(parseHunk("@@@ -1 -1 +1 @@@") == null); +} + +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;" }; + 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); +} diff --git a/src/exec.zig b/src/exec.zig index d114dbf8..5e223f04 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -738,6 +738,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { }; p.gpa.free(pane.command.?); pane.command = owned; + p.diff_output.drop(p.gpa, pane.serial); pane.command_done = false; pane.command_status = null; pane.command_pty = true; diff --git a/src/pardes.zig b/src/pardes.zig index b16d39b7..9fe6a1fb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4502,6 +4502,9 @@ pub const Pardes = struct { /// the terminal motion surface, memoized against the pane it was built /// for — see panes.terminal.RowsCache for the lifetime rule shell_rows: panes.terminal.RowsCache = .{}, + /// Finished command panes' output, and whether it is a diff + /// (File.DiffOutput). + diff_output: panes.File.DiffOutput = .{}, /// The message row's entry points, called from everywhere as /// `p.setMessage(...)`; they live with the rest of it in Messages.zig. @@ -4752,6 +4755,7 @@ pub const Pardes = struct { p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); + p.diff_output.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); diff --git a/src/syntax.zig b/src/syntax.zig index 8ed00bfa..b7406b04 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -5,6 +5,11 @@ const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); +const diff = @import("diff.zig"); + +test { + _ = diff; +} pub const enabled = config.syntax_highlighting; const zig_grammar = config.syntax_zig_grammar; @@ -183,6 +188,7 @@ pub fn stop() void { spec.objects = null; spec.objects_failed = false; } + dropDiffPieces(); ts_set_allocator(null, null, null, null); syntax_started = false; syntax_allocator = undefined; @@ -1029,35 +1035,269 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node { return null; } -pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { - const start_byte = @min(start_byte_raw, content.len); - const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); - const source = content[start_byte..end_byte]; - const styles = try gpa.alloc(u8, source.len); +/// A diff line's style byte carries whether the line was added or removed +/// over its syntax style (the low bits), so the painter can tint its row +/// and mark its prefix. +pub const diff_added: u8 = 0x80; +pub const diff_removed: u8 = 0x40; +pub const syn_bits: u8 = 0x3f; + +/// A file section's hunks are parsed together, each side as one text, in +/// pieces: a piece takes whole hunks until it holds `diff_piece_lines` +/// lines, and a hunk longer than `diff_piece_cut` is cut there, counted +/// from the section's first hunk. So a view parses about what a file's +/// view does (its rows and a margin), however many small hunks it shows, +/// and a 20k-line new file is parsed where it is shown, not whole. A string +/// or comment across a cut colours as a fragment would. +const diff_piece_lines = 40; +const diff_piece_cut = 80; + +/// Painted hunk pieces, keyed by their bytes and language: scrolling back +/// over a hunk, or a terminal repainting the same output every frame, +/// parses nothing again. Freed by `stop`. +const DiffPiece = struct { hash: u64, styles: []u8, used: u64 }; +var diff_pieces: [96]?DiffPiece = @splat(null); +var diff_clock: u64 = 0; + +fn dropDiffPieces() void { + for (&diff_pieces) |*slot| { + if (slot.*) |piece| syntax_allocator.free(piece.styles); + slot.* = null; + } + diff_clock = 0; +} + +/// The styles of `content[start..end)` read as a unified diff: headers +/// muted, `@@` lines as keywords, and each hunk's code in the language of +/// the file its section names. A hunk's old side (context and removed +/// lines) and new side (context and added lines) are each parsed whole, so +/// a string or comment that spans lines colours as it does in the file. +/// Added and removed lines carry `diff_added`/`diff_removed`. A file in no +/// language pardes knows keeps the plain colours: added lines as strings, +/// removed ones as numbers. +pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 { + return highlightDiffRows(gpa, content, start_raw, end_raw, false); +} + +/// The same for a terminal's rows, where a row the terminal wrapped +/// continues the diff line above it. +pub fn highlightDiffRows(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize, continued_rows: bool) ![]u8 { + const tz = tracy.zone(@src(), "highlightDiff"); + defer tz.end(); + const start_byte = @min(start_raw, content.len); + const end_byte = @max(start_byte, @min(end_raw, content.len)); + const styles = try gpa.alloc(u8, end_byte - start_byte); errdefer gpa.free(styles); @memset(styles, 0); + const Window = struct { + styles: []u8, + start: usize, + fn put(w: @This(), at: usize, bytes: []const u8) void { + const lo = @max(at, w.start); + const hi = @min(at + bytes.len, w.start + w.styles.len); + if (lo < hi) @memcpy(w.styles[lo - w.start .. hi - w.start], bytes[lo - at .. hi - at]); + } + fn fill(w: @This(), at: usize, len: usize, style: u8) void { + const lo = @max(at, w.start); + const hi = @min(at + len, w.start + w.styles.len); + if (lo < hi) @memset(w.styles[lo - w.start .. hi - w.start], style); + } + }; + const window: Window = .{ .styles = styles, .start = start_byte }; + + var walk: diff.Walk = .{ .continued_rows = continued_rows }; + // The run of hunk lines not painted yet, with each line's kind. + var piece_start: ?usize = null; + var piece_end: usize = 0; + var piece_path: []const u8 = ""; + var kinds: [diff_piece_cut]diff.Kind = undefined; + var nkinds: usize = 0; + var at = diff.anchorBefore(content, start_byte); + while (true) { + const nl = std.mem.indexOfScalarPos(u8, content, at, '\n') orelse content.len; + const got = walk.step(content[at..nl]); + const body = switch (got.kind) { + .context, .added, .removed, .no_newline => true, + // The next hunk of the section goes on in the same piece. + .hunk => piece_start != null and nkinds < diff_piece_lines, + else => false, + }; + if (piece_start) |ps| if (!body or nkinds == diff_piece_cut) { + if (piece_end >= start_byte) try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path); + piece_start = null; + nkinds = 0; + }; + if (at >= end_byte and piece_start == null) break; + if (body and (piece_start != null or got.kind != .hunk)) { + if (piece_start == null) { + piece_start = at; + piece_path = walk.path(); + } + kinds[nkinds] = got.kind; + nkinds += 1; + piece_end = nl; + } else if (nl >= start_byte) { + const style: Syn = switch (got.kind) { + .hunk => .keyword, + .meta, .old_path, .new_path => .comment, + else => .none, + }; + if (style != .none) window.fill(at, nl - at, @intFromEnum(style)); + } + if (nl >= content.len) break; + at = nl + 1; + } + if (piece_start) |ps| if (piece_end >= start_byte) + try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path); + return styles; +} +fn paintDiffPiece(gpa: std.mem.Allocator, window: anytype, bytes: []const u8, at: usize, kinds: []const diff.Kind, path: []const u8) !void { + const selected: ?Selected = if (enabled and syntax_started) (forExt(std.fs.path.extension(path)) catch null) else null; + if (selected) |sel| { + const hash = std.hash.Wyhash.hash(std.hash.Wyhash.hash(0, sel.name), bytes); + diff_clock += 1; + for (&diff_pieces) |*slot| if (slot.*) |*piece| if (piece.hash == hash and piece.styles.len == bytes.len) { + piece.used = diff_clock; + return window.put(at, piece.styles); + }; + const colored = try syntax_allocator.alloc(u8, bytes.len); + errdefer syntax_allocator.free(colored); + try paintHunk(gpa, colored, bytes, kinds, sel); + window.put(at, colored); + var oldest: usize = 0; + for (&diff_pieces, 0..) |*slot, i| { + const piece = slot.* orelse { + oldest = i; + break; + }; + if (diff_pieces[oldest]) |o| if (piece.used < o.used) { + oldest = i; + }; + } + if (diff_pieces[oldest]) |old| syntax_allocator.free(old.styles); + diff_pieces[oldest] = .{ .hash = hash, .styles = colored, .used = diff_clock }; + return; + } + // No language: each line in one colour, as diffs were always shown. + var lines = std.mem.splitScalar(u8, bytes, '\n'); var offset: usize = 0; - var lines = std.mem.splitScalar(u8, source, '\n'); - while (lines.next()) |line| { - const syn = diffLineSyn(line); - if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn)); + for (kinds) |kind| { + const line = lines.next() orelse break; + const style: u8 = switch (kind) { + .added => @intFromEnum(Syn.string) | diff_added, + .removed => @intFromEnum(Syn.number) | diff_removed, + .no_newline => @intFromEnum(Syn.comment), + .hunk => @intFromEnum(Syn.keyword), + else => 0, + }; + if (style != 0) window.fill(at + offset, line.len, style); offset += line.len + 1; } - return styles; } -fn diffLineSyn(line: []const u8) Syn { - if (std.mem.startsWith(u8, line, "@@")) return .keyword; - if (std.mem.startsWith(u8, line, "+++") or - std.mem.startsWith(u8, line, "---") or - std.mem.startsWith(u8, line, "diff ") or - std.mem.startsWith(u8, line, "index ") or - std.mem.startsWith(u8, line, "\\ No newline")) return .comment; - if (line.len == 0) return .none; - if (line[0] == '+') return .string; - if (line[0] == '-') return .number; - return .none; +/// One piece's styles: its old side and its new side each parsed as one +/// text, and every line's code coloured from its side, behind its +/// one-character prefix. A `@@` line between its hunks is in neither. +fn paintHunk(gpa: std.mem.Allocator, out: []u8, bytes: []const u8, kinds: []const diff.Kind, sel: Selected) !void { + @memset(out, 0); + const old_src = try gpa.alloc(u8, bytes.len); + defer gpa.free(old_src); + const new_src = try gpa.alloc(u8, bytes.len); + defer gpa.free(new_src); + var old_len: usize = 0; + var new_len: usize = 0; + var lines = std.mem.splitScalar(u8, bytes, '\n'); + for (kinds) |kind| { + const line = lines.next() orelse break; + const code = line[codeStart(line, kind)..]; + // A row a terminal wrapped goes on the line above, as it was printed. + const joined = continuesRow(line, kind); + if (kind == .context or kind == .removed) { + if (joined and old_len > 0) old_len -= 1; + @memcpy(old_src[old_len..][0..code.len], code); + old_len += code.len; + old_src[old_len] = '\n'; + old_len += 1; + } + if (kind == .context or kind == .added) { + if (joined and new_len > 0) new_len -= 1; + @memcpy(new_src[new_len..][0..code.len], code); + new_len += code.len; + new_src[new_len] = '\n'; + new_len += 1; + } + } + // A side is parsed only when a line takes its colours from it: the old + // side for removed lines, the new side for added lines, and context + // from the new side, else from the old. A piece that only adds or only + // removes is one parse. + var removes = false; + var adds = false; + for (kinds) |kind| { + removes = removes or kind == .removed; + adds = adds or kind == .added; + } + const context_new = adds or !removes; + const old_colors = try gpa.alloc(u8, old_len); + defer gpa.free(old_colors); + @memset(old_colors, 0); + if (!context_new or removes) paint(old_colors, old_src[0..old_len], sel); + const new_colors = try gpa.alloc(u8, new_len); + defer gpa.free(new_colors); + @memset(new_colors, 0); + if (context_new) paint(new_colors, new_src[0..new_len], sel); + + lines = std.mem.splitScalar(u8, bytes, '\n'); + var offset: usize = 0; + var old_at: usize = 0; + var new_at: usize = 0; + for (kinds) |kind| { + const line = lines.next() orelse break; + defer offset += line.len + 1; + const skip = codeStart(line, kind); + const code_len = line.len - skip; + const dst = out[offset + skip ..][0..code_len]; + if (continuesRow(line, kind)) { + if ((kind == .context or kind == .removed) and old_at > 0) old_at -= 1; + if ((kind == .context or kind == .added) and new_at > 0) new_at -= 1; + } + switch (kind) { + .removed => @memcpy(dst, old_colors[old_at..][0..code_len]), + .added => @memcpy(dst, new_colors[new_at..][0..code_len]), + .context => @memcpy(dst, if (context_new) new_colors[new_at..][0..code_len] else old_colors[old_at..][0..code_len]), + .no_newline => @memset(out[offset..][0..line.len], @intFromEnum(Syn.comment)), + .hunk => @memset(out[offset..][0..line.len], @intFromEnum(Syn.keyword)), + else => {}, + } + if (kind == .context or kind == .removed) old_at += code_len + 1; + if (kind == .context or kind == .added) new_at += code_len + 1; + const flag: u8 = switch (kind) { + .added => diff_added, + .removed => diff_removed, + else => 0, + }; + if (flag != 0) for (out[offset..][0..line.len]) |*b| { + b.* |= flag; + }; + } +} + +fn continuesRow(line: []const u8, kind: diff.Kind) bool { + return line.len > 0 and codeStart(line, kind) == 0 and + (kind == .context or kind == .added or kind == .removed); +} + +/// Where a hunk line's code starts: past its prefix, which a blank context +/// line (its space stripped) and a terminal's wrapped row have none of. +fn codeStart(line: []const u8, kind: diff.Kind) usize { + if (line.len == 0) return 0; + return switch (kind) { + .context => @intFromBool(line[0] == ' '), + .added => @intFromBool(line[0] == '+'), + .removed => @intFromBool(line[0] == '-'), + else => 0, + }; } test "syntax a results row is coloured by the file its location names" { @@ -1268,31 +1508,215 @@ test "syntax plain Markdown keeps block styles without starting the inline parse } test "syntax highlightDiff colors unified diff lines by prefix" { - const diff = + const diff_text = "diff --git a/x b/x\n" ++ "--- a/x\n" ++ "+++ b/x\n" ++ - "@@ -1,3 +1,3 @@\n" ++ + "@@ -1,2 +1,2 @@\n" ++ " context\n" ++ "-old line\n" ++ "+new line\n"; - const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len); + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "diff --git", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "--- a/x", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "+++ b/x", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -1,2", 0)); + try std.testing.expectEqual(Syn.none, diffSynAt(styles, diff_text, " context", 0)); + // A file in no known language: each line in one colour, and tinted. + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "-old line", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+new line", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "-old line").?] & diff_removed != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "+new line").? + 4] & diff_added != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, " context").?] & (diff_added | diff_removed) == 0); +} - const byteSyn = struct { - fn at(s: []const u8, src: []const u8, needle: []const u8) Syn { - const i = std.mem.indexOf(u8, src, needle).?; - return @enumFromInt(s[i]); - } - }.at; +fn diffSynAt(styles: []const u8, text: []const u8, needle: []const u8, offset: usize) Syn { + return @enumFromInt(styles[std.mem.indexOf(u8, text, needle).? + offset] & syn_bits); +} - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git")); - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x")); - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x")); - try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3")); - try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context")); - try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line")); - try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line")); +test "syntax a Zig hunk is coloured as Zig, each side parsed whole" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "diff --git a/src/shape.zig b/src/shape.zig\n" ++ + "index 1111111..2222222 100644\n" ++ + "--- a/src/shape.zig\n" ++ + "+++ b/src/shape.zig\n" ++ + "@@ -1,6 +1,6 @@\n" ++ + " const Shape = struct {\n" ++ + "- side: u8 = 7,\n" ++ + "+ side: u16 = 42,\n" ++ + " const note =\n" ++ + "- \\\\old words defer\n" ++ + "+ \\\\new words defer\n" ++ + " ;\n" ++ + " };\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "struct", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "7,", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "42", 0)); + // A multiline string, told apart only by parsing the side it is on: + // `defer` inside it is no keyword. + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "old words defer", 10)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "new words defer", 10)); + // The prefix is no code: it carries the tint alone. + const added = std.mem.indexOf(u8, diff_text, "+ side").?; + try std.testing.expectEqual(diff_added, styles[added]); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "42").?] & diff_added != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "7,").?] & diff_removed != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "index 111", 0)); + + // A window in the middle is coloured as the whole is. + const from = std.mem.indexOf(u8, diff_text, " const note").?; + const part = try highlightDiff(std.testing.allocator, diff_text, from, diff_text.len); + defer std.testing.allocator.free(part); + try std.testing.expectEqualSlices(u8, styles[from..], part); +} + +test "syntax a Python hunk is coloured as Python" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "--- a/tool.py\n" ++ + "+++ b/tool.py\n" ++ + "@@ -1,3 +1,4 @@\n" ++ + " def run(n):\n" ++ + "- return n\n" ++ + "+ # doubled now\n" ++ + "+ return n * 2\n" ++ + " \n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "def", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "return n\n", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "# doubled", 2)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2\n", 0)); +} + +test "syntax a deleted file's lines are coloured in its language" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "diff --git a/gone.zig b/gone.zig\n" ++ + "deleted file mode 100644\n" ++ + "--- a/gone.zig\n" ++ + "+++ /dev/null\n" ++ + "@@ -1,2 +0,0 @@\n" ++ + "-const Gone = enum { a };\n" ++ + "-// the end\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "enum", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "// the end", 3)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "enum").?] & diff_removed != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "deleted file", 0)); +} + +test "syntax a hunk in no known language keeps the line colours" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "--- a/notes.unknownext\n" ++ + "+++ b/notes.unknownext\n" ++ + "@@ -1 +1 @@\n" ++ + "-const struct 1\n" ++ + "+const struct 2\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "struct 1", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "struct 2", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+const struct 2", 0)); +} + +test "syntax git diff output in a command pane is told and coloured by its rows" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + // A command pane's rows: its command line, then git's output, with one + // long added line the terminal wrapped onto a second row. + const rows = [_][]const u8{ + "git diff", + "diff --git a/main.zig b/main.zig", + "--- a/main.zig", + "+++ b/main.zig", + "@@ -1,2 +1,2 @@", + " pub fn main() void {", + "+ const answer: u32 = 42; // the ", + "wrapped rest", + "- return;", + "", + }; + try std.testing.expect(diff.looksLikeDiff(&rows)); + try std.testing.expect(!diff.looksLikeDiff(rows[5..])); + const text = try std.mem.join(std.testing.allocator, "\n", &rows); + defer std.testing.allocator.free(text); + const styles = try highlightDiffRows(std.testing.allocator, text, 0, text.len, true); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.none, diffSynAt(styles, text, "git diff", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, text, "fn main", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, text, "42", 0)); + // The wrapped row stays an added line, and inside the comment. + try std.testing.expect(styles[std.mem.indexOf(u8, text, "wrapped rest").?] & diff_added != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, text, "wrapped rest", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, text, "return").?] & diff_removed != 0); +} + +test "syntax a section's small hunks share one parse" { + if (!enabled) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const diff_text = + "--- a/x.zig\n+++ b/x.zig\n" ++ + "@@ -1 +1 @@\n-const a = 1;\n+const a = 2;\n" ++ + "@@ -10 +10 @@\n-const b = 1;\n+const b = 2;\n" ++ + "@@ -20 +20 @@\n-const c = 1;\n+const c = 2;\n" ++ + "--- a/y.zig\n+++ b/y.zig\n" ++ + "@@ -1 +1 @@\n-const d = 1;\n+const d = 2;\n"; + const styles = try highlightDiff(gpa, diff_text, 0, diff_text.len); + defer gpa.free(styles); + var cached: usize = 0; + for (diff_pieces) |slot| cached += @intFromBool(slot != null); + try std.testing.expectEqual(@as(usize, 2), cached); // x.zig's three hunks, and y.zig's + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -10", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2;\n@@ -20", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "@@ -10").?] & (diff_added | diff_removed) == 0); +} + +test "syntax a hunk painted once is kept, and a long one is parsed in pieces" { + if (!enabled) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + var text: std.Io.Writer.Allocating = .init(gpa); + defer text.deinit(); + const n = diff_piece_cut * 20 + 10; + try text.writer.print("--- /dev/null\n+++ b/big.zig\n@@ -0,0 +1,{d} @@\n", .{n}); + for (0..n) |i| try text.writer.print("+const v{d} = {d};\n", .{ i, i }); + const content = text.written(); + const at = std.mem.indexOf(u8, content, "+const v1000 ").?; + const window = try highlightDiff(gpa, content, at, at + 200); + defer gpa.free(window); + try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(window["+const v1000 = ".len] & syn_bits))); + var cached: usize = 0; + for (diff_pieces) |slot| if (slot) |piece| { + cached += 1; + // One piece of many, not the whole hunk. + try std.testing.expect(piece.styles.len < content.len / 4); + }; + try std.testing.expectEqual(@as(usize, 1), cached); + const again = try highlightDiff(gpa, content, at, at + 200); + defer gpa.free(again); + try std.testing.expectEqualSlices(u8, window, again); + cached = 0; + for (diff_pieces) |slot| cached += @intFromBool(slot != null); + try std.testing.expectEqual(@as(usize, 1), cached); } test "syntax result fragments preserve source indentation and inline markup" { @@ -1526,3 +1950,4 @@ test "syntax source analysis preserves declarations and injected colors" { try std.testing.expectEqual(@as(usize, 0), unsupported.declarations.len); try std.testing.expectEqual(@as(usize, 0), unsupported.colors.len); } + |
