Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `--check`: the whitespace errors and leftover conflict markers the//! lines a diff adds bring in, with the rules `core.whitespace` and the//! `whitespace` attribute choose (git-config(1), gitattributes(5)).//!//! What the documentation leaves out was found by asking git 2.56 about//! files of lines chosen one feature at a time://!//! - **Trailing whitespace** (`blank-at-eol`) is a space, tab or carriage//! return at the end of the line (a vertical tab or form feed is not);//! with `cr-at-eol` one carriage return at the end is set aside first.//! - The **indent** is the spaces and tabs a line begins with, before//! anything else. `space-before-//! tab` finds a space just before a tab there (under `blank-at-eol`//! the indent stops where trailing whitespace begins); `indent-with-non-tab`//! finds at least `tabwidth` spaces after its last tab; `tab-in-indent`//! finds a tab there, other than one `space-before-tab` reports.//! The messages are joined in that order, after "trailing whitespace",//! and the line follows them, `+` before it.//! - A **conflict marker** is a line of exactly `conflict-marker-size`//! (7) of `<`, `=`, `>` or `|`, followed by a space for `<` and `>`, and//! by a space, tab, carriage return or nothing for `=` and `|`. It is reported before the//! line's other errors, on its own.//! - `incomplete-line` reports a last line without a newline on its own//! line of output, after the line's other errors.//! - `blank-at-eof`: where the new file ends in more lines of whitespace//! alone than the old one did, and something else comes before them,//! the first of them is reported, after the rest of the file (whether//! the patch shows them as added does not matter).//! - `core.whitespace`'s words are case-sensitive, unknown ones passed//! over, `-` turning one off; `tabwidth=<n>` reads leading digits and//! warns of a value outside 1 to 63. The `whitespace` attribute set//! turns on all but `tab-in-indent` and `cr-at-eol`, unset turns all//! off, and a value is read like `core.whitespace` from git's defaults.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const LineDiff = @import("LineDiff.zig");
/// Which whitespace errors are noticed.pub const Rules = struct { blank_at_eol: bool = true, space_before_tab: bool = true, indent_with_non_tab: bool = false, tab_in_indent: bool = false, blank_at_eof: bool = true, cr_at_eol: bool = false, incomplete_line: bool = false, tab_width: u8 = 8,
/// git's defaults. pub const defaults: Rules = .{}; /// Nothing noticed: the `whitespace` attribute unset. pub const nothing: Rules = .{ .blank_at_eol = false, .space_before_tab = false, .blank_at_eof = false };
/// The `whitespace` attribute set: everything but `tab-in-indent` and /// `cr-at-eol`, with `base`'s tab width. pub fn everything(base: Rules) Rules { return .{ .indent_with_non_tab = true, .incomplete_line = true, .tab_width = base.tab_width }; }
/// Whether both `tab-in-indent` and `indent-with-non-tab` are on, /// which git refuses. pub fn conflicting(r: Rules) bool { return r.tab_in_indent and r.indent_with_non_tab; }
/// Reads a `core.whitespace` value onto `base`. `bad_width`, when /// given, is set to a `tabwidth` value out of range (the last), for /// git's warning. pub fn parse(base: Rules, text: []const u8, bad_width: ?*?[]const u8) Rules { var r = base; var it = std.mem.splitScalar(u8, text, ','); while (it.next()) |word| { const off = word.len != 0 and word[0] == '-'; const name = if (off) word[1..] else word; if (std.mem.startsWith(u8, name, "tabwidth=")) { if (off) continue; const v = name["tabwidth=".len..]; var n: u32 = 0; for (v) |c| { if (c < '0' or c > '9') break; n = @min(n * 10 + (c - '0'), 1000); } if (n >= 1 and n <= 63) r.tab_width = @intCast(n) else if (bad_width) |b| { b.* = v; } continue; } const on = !off; if (std.mem.eql(u8, name, "blank-at-eol")) { r.blank_at_eol = on; } else if (std.mem.eql(u8, name, "space-before-tab")) { r.space_before_tab = on; } else if (std.mem.eql(u8, name, "indent-with-non-tab")) { r.indent_with_non_tab = on; } else if (std.mem.eql(u8, name, "tab-in-indent")) { r.tab_in_indent = on; } else if (std.mem.eql(u8, name, "blank-at-eof")) { r.blank_at_eof = on; } else if (std.mem.eql(u8, name, "trailing-space")) { r.blank_at_eol = on; r.blank_at_eof = on; } else if (std.mem.eql(u8, name, "cr-at-eol")) { r.cr_at_eol = on; } else if (std.mem.eql(u8, name, "incomplete-line")) { r.incomplete_line = on; } } return r; }};
/// The errors found on one line.pub const Problems = struct { trailing: bool = false, space_before_tab: bool = false, indent_with_spaces: bool = false, tab_in_indent: bool = false,
/// Whether any was found. pub fn any(p: Problems) bool { return p.trailing or p.space_before_tab or p.indent_with_spaces or p.tab_in_indent; }};
fn isBlank(c: u8) bool { return c == ' ' or c == '\t' or c == '\r';}
/// The whitespace errors of `line`, without its newline.pub fn lineProblems(r: Rules, line: []const u8) Problems { var p: Problems = .{}; var text = line; if (r.cr_at_eol and text.len != 0 and text[text.len - 1] == '\r') text = text[0 .. text.len - 1]; // Under blank-at-eol the indent is looked for only before the // trailing whitespace, so a line of whitespace alone has none. var end = line.len; if (r.blank_at_eol) { end = text.len; while (end > 0 and isBlank(text[end - 1])) end -= 1; if (end != text.len) p.trailing = true; } var indent: usize = 0; while (indent < end and (line[indent] == ' ' or line[indent] == '\t')) indent += 1; const lead = line[0..indent]; if (r.space_before_tab and std.mem.find(u8, lead, " \t") != null) p.space_before_tab = true; if (r.indent_with_non_tab) { const after = if (std.mem.findScalarLast(u8, lead, '\t')) |t| lead[t + 1 ..] else lead; if (after.len >= r.tab_width) p.indent_with_spaces = true; } if (r.tab_in_indent) for (lead, 0..) |c, i| { // A tab space-before-tab reports is not reported again. if (c == '\t' and !(r.space_before_tab and i > 0 and lead[i - 1] == ' ')) p.tab_in_indent = true; }; return p;}
/// Whether `line`, without its newline, is a leftover conflict marker of/// `size` characters.pub fn isConflictMarker(line: []const u8, size: usize) bool { if (size == 0 or line.len < size) return false; const c = line[0]; if (c != '<' and c != '=' and c != '>' and c != '|') return false; for (line[0..size]) |x| if (x != c) return false; const next: ?u8 = if (line.len > size) line[size] else null; return switch (c) { '<', '>' => next != null and next.? == ' ', // A vertical tab or form feed does not count, as git was seen // to take them. else => next == null or next.? == ' ' or next.? == '\t' or next.? == '\r', };}
/// How the problems are written.pub const Options = struct { rules: Rules = .{}, /// `conflict-marker-size`. marker_size: usize = 7, /// `core.quotePath`, which git was seen not to apply here. quote_path: bool = true, /// How lines are compared: a file whose changes the whitespace /// options hide whole is passed over, as git was seen to pass it. lines: LineDiff.Options = .{},};
/// What `write` can fail with.pub const Error = LineDiff.Error || Io.Writer.Error || error{ /// `blank-at-eof` for a file of blank lines alone, on either side, /// where git was seen to choose the line it reports by a rule not /// matched. BlankFileUnsupported,};
/// Writes what `--check` finds in the lines the diff from `old` to `new`/// adds, for `path`; returns whether it found anything.pub fn write(gpa: Allocator, w: *Io.Writer, path: []const u8, old: []const u8, new: []const u8, options: Options) Error!bool { // A file whose changes the whitespace options hide whole is passed // over; otherwise the lines are those the plain line diff adds, as // git was seen to check them. if (options.lines.whitespace.any()) { var hidden = try LineDiff.compute(gpa, old, new, options.lines); defer hidden.deinit(gpa); const any = std.mem.findScalar(bool, hidden.a_changed, true) != null or std.mem.findScalar(bool, hidden.b_changed, true) != null; if (!any) return false; } var plain = options.lines; plain.whitespace = .{}; var d = try LineDiff.compute(gpa, old, new, plain); defer d.deinit(gpa); const r = options.rules; if (r.blank_at_eof and d.b.len() != 0 and isBlankLine(d.b.line(d.b.len() - 1))) { if (allBlank(d.b) or (d.a.len() != 0 and allBlank(d.a))) return error.BlankFileUnsupported; } var found = false; const n = d.b.len(); for (0..n) |i| { if (!d.b_changed[i]) continue; const full = d.b.line(i); const complete = full.len != 0 and full[full.len - 1] == '\n'; const line = if (complete) full[0 .. full.len - 1] else full; if (isConflictMarker(line, options.marker_size)) { try writeWhere(w, path, i + 1, options); try w.writeAll("leftover conflict marker\n"); found = true; } const p = lineProblems(r, line); if (p.any()) { try writeWhere(w, path, i + 1, options); var first = true; const parts = [_]struct { bool, []const u8 }{ .{ p.trailing, "trailing whitespace" }, .{ p.space_before_tab, "space before tab in indent" }, .{ p.indent_with_spaces, "indent with spaces" }, .{ p.tab_in_indent, "tab in indent" }, }; for (parts) |part| if (part[0]) { if (!first) try w.writeAll(", "); try w.writeAll(part[1]); first = false; }; try w.writeAll(".\n+"); try w.writeAll(line); try w.writeByte('\n'); found = true; } if (!complete and r.incomplete_line) { try writeWhere(w, path, i + 1, options); try w.writeAll("no newline at the end of file.\n"); found = true; } } if (r.blank_at_eof and n != 0) { var start = n; while (start > 0 and isBlankLine(d.b.line(start - 1))) start -= 1; var old_start = d.a.len(); while (old_start > 0 and isBlankLine(d.a.line(old_start - 1))) old_start -= 1; // More blank lines at the end than the old file had, and something // else before them. if (start > 0 and n - start > d.a.len() - old_start) { try writeWhere(w, path, start + 1, options); try w.writeAll("new blank line at EOF.\n"); found = true; } } return found;}
fn allBlank(lines: LineDiff.Lines) bool { for (0..lines.len()) |i| if (!isBlankLine(lines.line(i))) return false; return true;}
fn isBlankLine(line: []const u8) bool { for (line) |c| if (!isBlank(c) and c != '\n') return false; return true;}
fn writeWhere(w: *Io.Writer, path: []const u8, line: usize, options: Options) Io.Writer.Error!void { _ = options; // The path is written as it is, never quoted, as git was seen to. try w.print("{s}:{d}: ", .{ path, line });}
test "whitespace problems of a line" { const d: Rules = .defaults; try std.testing.expect(lineProblems(d, "a \t").trailing); try std.testing.expect(lineProblems(d, "x\r").trailing); try std.testing.expect(!lineProblems(d, "x\x0b").trailing); try std.testing.expect(!lineProblems(.parse(d, "cr-at-eol", null), "x\r").trailing); try std.testing.expect(lineProblems(d, " \tx").space_before_tab); try std.testing.expect(!lineProblems(d, " \t").space_before_tab); const iwnt = Rules.parse(d, "indent-with-non-tab", null); try std.testing.expect(lineProblems(iwnt, " x").indent_with_spaces); try std.testing.expect(!lineProblems(iwnt, " x").indent_with_spaces); try std.testing.expect(!lineProblems(iwnt, " \tx").indent_with_spaces); try std.testing.expect(lineProblems(iwnt, "\t x").indent_with_spaces); const tii = Rules.parse(d, "tab-in-indent", null); try std.testing.expect(lineProblems(tii, "\t x").tab_in_indent); try std.testing.expect(!lineProblems(tii, " \tx").tab_in_indent); try std.testing.expect(lineProblems(tii, " \t\tx").tab_in_indent); try std.testing.expect(!lineProblems(tii, " \t \tx").tab_in_indent); var bad: ?[]const u8 = null; _ = Rules.parse(d, "tabwidth=x", &bad); try std.testing.expectEqualStrings("x", bad.?);}
test "conflict markers" { try std.testing.expect(isConflictMarker("<<<<<<< x", 7)); try std.testing.expect(!isConflictMarker("<<<<<<<", 7)); try std.testing.expect(!isConflictMarker("<<<<<<<\tx", 7)); try std.testing.expect(isConflictMarker("=======", 7)); try std.testing.expect(isConflictMarker("=======\r", 7)); try std.testing.expect(!isConflictMarker("=======x", 7)); try std.testing.expect(!isConflictMarker("========", 7)); try std.testing.expect(isConflictMarker("|||||||", 7)); try std.testing.expect(!isConflictMarker("<<<<<<<< x", 7));}
const fuzz_input = @import("../testing/fuzz.zig");
/// A line, a `core.whitespace` value, then a text: rules read, a line/// judged, and the problems of the text against an empty old one written.fn fuzzCheck(_: void, smith: *std.testing.Smith) anyerror!void { const gpa = std.testing.allocator; var buffer: [512]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); var parts = std.mem.splitScalar(u8, input, '\n'); const line = parts.next() orelse return; const rules = Rules.parse(.defaults, parts.next() orelse "", null); _ = lineProblems(rules, line); _ = isConflictMarker(line, 7); var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); _ = write(gpa, &out.writer, "f", "", parts.rest(), .{ .rules = rules }) catch |err| switch (err) { error.BlankFileUnsupported => return, else => |e| return e, };}
test "fuzz: whitespace errors" { try fuzz_input.fuzz({}, fuzzCheck, .{ .corpus = &.{@embedFile("../fuzz/whitespace-rules")} });}