// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! What the find bar is looking for, and, for what grebe can answer //! itself, the answer, a row at a time. //! //! As in gitk, a find is a mode — commits containing some text, touching //! some paths, adding or removing a string, or changing lines that match //! — and, for text, how it matches and in which fields. The subject and the //! author are in the history already, and are matched here as the text is //! typed. Everything else — the rest of the message, the committer, paths, //! and what the diffs do — is git's to answer, with `git log` and the same //! revision arguments; see `git/Find.zig`. const std = @import("std"); const Allocator = std.mem.Allocator; const zregex = @import("zregex"); const History = @import("../history/History.zig"); const Query = @This(); pub const Mode = enum { /// Commits whose fields contain the text. containing, /// Commits changing any of the paths, separated by spaces. touching_paths, /// Commits changing how many times the string occurs: `git log -S`. adding_removing, /// Commits with an added or removed line that matches: `git log -G`. changing_lines, /// The message naming it. pub fn label(m: Mode) []const u8 { return switch (m) { .containing => "find-mode-containing", .touching_paths => "find-mode-touching-paths", .adding_removing => "find-mode-adding-removing", .changing_lines => "find-mode-changing-lines", }; } }; pub const Matching = enum { exact, ignore_case, regex, /// The message naming it. pub fn label(m: Matching) []const u8 { return switch (m) { .exact => "find-matching-exact", .ignore_case => "find-matching-ignore-case", .regex => "find-matching-regex", }; } }; pub const Fields = enum { /// The subject: a message's first line. headline, /// The rest of the message too. comments, author, committer, all, /// The message naming it. pub fn label(f: Fields) []const u8 { return switch (f) { .headline => "find-fields-headline", .comments => "find-fields-comments", .author => "find-fields-author", .committer => "find-fields-committer", .all => "find-fields-all", }; } }; text: []const u8 = "", mode: Mode = .containing, matching: Matching = .exact, fields: Fields = .all, /// Whether grebe matches this itself, from the history, rather than /// asking git. pub fn local(q: Query) bool { return q.mode == .containing and (q.fields == .headline or q.fields == .author); } /// The text matched, compiled; for a local query. pub const Matcher = struct { gpa: Allocator, query: Query, regex: ?zregex.Regex = null, /// The text lowercased, for ignoring case. folded: []u8 = &.{}, pub const Error = Allocator.Error || error{InvalidPattern}; pub fn init(gpa: Allocator, q: Query) Error!Matcher { var m: Matcher = .{ .gpa = gpa, .query = q }; switch (q.matching) { .exact => {}, .ignore_case => m.folded = try std.ascii.allocLowerString(gpa, q.text), .regex => m.regex = zregex.Regex.compile(gpa, q.text) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.InvalidPattern, }, } return m; } pub fn deinit(m: *Matcher) void { if (m.regex) |*r| r.deinit(); m.gpa.free(m.folded); m.* = undefined; } /// Whether `s` matches. pub fn matchesText(m: *const Matcher, s: []const u8) bool { if (m.query.text.len == 0) return false; return switch (m.query.matching) { .exact => std.mem.find(u8, s, m.query.text) != null, .ignore_case => std.ascii.findIgnoreCase(s, m.folded) != null, // A search the engine gives up on is no match. .regex => m.regex.?.isMatch(m.gpa, s) catch false, }; } /// Whether the commit in `row` matches. pub fn matchesRow(m: *const Matcher, h: *const History, row: usize) bool { const c = h.get(row); if (c.pseudo != .none) return false; return switch (m.query.fields) { .headline => m.matchesText(h.text(c.subject)), .author => blk: { const p = h.person(c.author); break :blk m.matchesText(h.text(p.name)) or m.matchesText(h.text(p.email)); }, else => false, }; } }; /// What `git log` is run with to find this, once or more: options, or for /// paths, `--` and the paths. Each run's matches are added to the last's. /// They are allocated with `a`, or point into `q.text`. /// /// "All fields" is three runs, since git takes a commit matching `--grep` /// and `--author` together only if it matches both. pub fn gitArgs(q: Query, a: Allocator) Allocator.Error![]const []const []const u8 { var runs: std.ArrayList([]const []const u8) = .empty; const regex = q.matching == .regex; switch (q.mode) { .containing => { const flavor: []const []const u8 = if (regex) &.{"--extended-regexp"} else if (q.matching == .ignore_case) &.{ "--fixed-strings", "--regexp-ignore-case" } else &.{"--fixed-strings"}; const grep = try std.fmt.allocPrint(a, "--grep={s}", .{q.text}); const author = try std.fmt.allocPrint(a, "--author={s}", .{q.text}); const committer = try std.fmt.allocPrint(a, "--committer={s}", .{q.text}); const which: []const []const u8 = switch (q.fields) { .headline, .comments => &.{grep}, .author => &.{author}, .committer => &.{committer}, .all => &.{ grep, author, committer }, }; for (which) |w| try runs.append(a, try std.mem.concat(a, []const u8, &.{ flavor, &.{w} })); }, else => { var out: std.ArrayList([]const u8) = .empty; try q.oneRun(a, &out); try runs.append(a, out.items); }, } return runs.items; } fn oneRun(q: Query, a: Allocator, out: *std.ArrayList([]const u8)) Allocator.Error!void { const regex = q.matching == .regex; switch (q.mode) { .containing => unreachable, .adding_removing => { try out.append(a, try std.fmt.allocPrint(a, "-S{s}", .{q.text})); if (regex) try out.append(a, "--pickaxe-regex"); }, .changing_lines => try out.append(a, try std.fmt.allocPrint(a, "-G{s}", .{q.text})), .touching_paths => { try out.append(a, "--"); var it = std.mem.tokenizeScalar(u8, q.text, ' '); while (it.next()) |p| try out.append(a, p); }, } } // --------------------------------------------------------------------------- const testing = std.testing; fn testHistory(h: *History) !void { var id: History.ObjectId = .{ .len = 20 }; id.bytes[0] = 1; try h.append(.{ .id = id, .parents = &.{}, .author_name = "Ada Lovelace", .author_email = "ada@example.com", .time = 0, .offset = 0, .subject = "Fix the Analytical Engine" }); id.bytes[0] = 2; try h.append(.{ .id = id, .parents = &.{}, .author_name = "Grace Hopper", .author_email = "grace@navy.mil", .time = 0, .offset = 0, .subject = "Find the moth" }); } test "exact, ignoring case, and by regex, in the headline and the author" { var h: History = .init(testing.allocator); defer h.deinit(); try testHistory(&h); const cases = [_]struct { q: Query, rows: [2]bool }{ .{ .q = .{ .text = "Engine", .fields = .headline }, .rows = .{ true, false } }, .{ .q = .{ .text = "engine", .fields = .headline }, .rows = .{ false, false } }, .{ .q = .{ .text = "engine", .fields = .headline, .matching = .ignore_case }, .rows = .{ true, false } }, .{ .q = .{ .text = "^F", .fields = .headline, .matching = .regex }, .rows = .{ true, true } }, .{ .q = .{ .text = "m[ao]th$", .fields = .headline, .matching = .regex }, .rows = .{ false, true } }, .{ .q = .{ .text = "navy", .fields = .author }, .rows = .{ false, true } }, .{ .q = .{ .text = "", .fields = .headline }, .rows = .{ false, false } }, }; for (cases) |c| { try testing.expect(c.q.local()); var m = try Matcher.init(testing.allocator, c.q); defer m.deinit(); for (c.rows, 0..) |want, row| try testing.expectEqual(want, m.matchesRow(&h, row)); } } test "a pattern that is not one says so" { try testing.expectError(error.InvalidPattern, Matcher.init(testing.allocator, .{ .text = "(", .matching = .regex, .fields = .headline })); } test "what git is asked" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); try testing.expect(!(Query{ .text = "x" }).local()); const all = try gitArgs(.{ .text = "x", .matching = .ignore_case }, a); try testing.expectEqual(@as(usize, 3), all.len); try testing.expectEqualStrings("--fixed-strings", all[0][0]); try testing.expectEqualStrings("--regexp-ignore-case", all[0][1]); try testing.expectEqualStrings("--author=x", all[1][2]); const paths = (try gitArgs(.{ .text = "src/a.zig docs", .mode = .touching_paths }, a))[0]; try testing.expectEqualSlices(u8, "--", paths[0]); try testing.expectEqual(@as(usize, 3), paths.len); const pick = (try gitArgs(.{ .text = "fn main", .mode = .adding_removing, .matching = .regex }, a))[0]; try testing.expectEqualStrings("-Sfn main", pick[0]); try testing.expectEqualStrings("--pickaxe-regex", pick[1]); }