Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455//! A search as it is typed, in Gmail's words, matched against the mail//! loaded so the list narrows at once; Enter asks Gmail itself.//!//! A word or "a phrase" matches who is on a thread, its subject, or its//! snippet. from:, to:, cc:, and subject: match one of those; label: and//! in: a label, by its name or Gmail's id, with /, -, and space alike;//! is:unread, is:read, and is:starred; before:, after:, older:, and//! newer: a date (2026/11/07, or seconds since the epoch), at midnight//! UTC; older_than: and newer_than: an age (3d, 2m, 1y). A - before a//! term leaves out what it matches. Every term must match. What Gmail//! knows and this does not, OR and other operators, is left to Gmail//! and ignored here. `found` tells where in a thread's text its words//! matched, for the list to mark them.
const std = @import("std");const Mailbox = @import("Mailbox.zig");const ascii = std.ascii;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const print = std.debug.print;
const Query = @This();
/// The terms, all of which a thread must match: the first `len`. A/// query is a line typed, so a few suffice, and past them terms are/// ignored. (std has no bounded list in 0.17.)terms: [max_terms]Term,len: u8,
const max_terms = 16;
pub const Term = struct { /// Whether it leaves out what it matches. negated: bool, what: What, /// Which of the mailbox's people, by place, a term naming people /// matches, found once rather than once a thread; empty for the rest. people: std.bit_set.Dynamic,};
/// What a term matches. Its text points into the typed line.pub const What = union(enum) { /// A word or phrase anywhere: who, subject, snippet. text: []const u8, from: []const u8, to: []const u8, cc: []const u8, subject: []const u8, label: []const u8, unread: bool, starred, /// A message before a time, or at or after it, in seconds since the epoch. before: i64, after: i64, /// Every thread, spam and trash too. anywhere,};
const Operator = enum { from, to, cc, subject, label, in, is, before, after, older, newer, older_than, newer_than };
const operators: std.StaticStringMap(Operator) = .initComptime(.{ .{ "from", .from }, .{ "to", .to }, .{ "cc", .cc }, .{ "subject", .subject }, .{ "label", .label }, .{ "in", .in }, .{ "is", .is }, .{ "before", .before }, .{ "after", .after }, .{ "older", .older }, .{ "newer", .newer }, .{ "older_than", .older_than }, .{ "newer_than", .newer_than },});
/// The query `text` asks of `mail`, with ages counted back from `now`,/// in seconds since the epoch. It points into `text`, and matches only/// the people `mail` has now.pub fn init(gpa: Allocator, text: []const u8, now: i64, mail: *const Mailbox) Allocator.Error!Query { var q = parse(text, now); errdefer q.deinit(gpa); for (q.terms[0..q.len]) |*t| { const s = switch (t.what) { .text, .from, .to, .cc => |s| s, else => continue, }; t.people = try .initEmpty(gpa, mail.people.count()); for (0..mail.people.count()) |i| { const p = mail.person(@intCast(i)); if (has(p.name, s) or has(p.address, s)) t.people.set(i); } } return q;}
pub fn deinit(q: *Query, gpa: Allocator) void { for (q.terms[0..q.len]) |*t| t.people.deinit(gpa); q.* = undefined;}
/// The query `text` asks, with ages counted back from `now`, matching no/// people of a mailbox: for one that is not, the search index's. It/// points into `text`, and owns nothing.pub fn parse(text: []const u8, now: i64) Query { var q: Query = .{ .terms = undefined, .len = 0 }; var rest = text; while (nextToken(&rest)) |token| { if (q.len == max_terms) break; const t = term(token, now) orelse continue; q.terms[q.len] = t; q.len += 1; } return q;}
/// Whether thread `t` of `mail` matches every term.pub fn matches(q: *const Query, mail: *const Mailbox, t: *const Mailbox.Thread) bool { for (q.terms[0..q.len]) |*term_| { if (matchesTerm(mail, t, term_) == term_.negated) return false; } return true;}
/// Where a thread's text shows: who is on it, its subject, its snippet.pub const Field = enum { who, subject, snippet };
/// Bytes of a field a term matched.pub const Range = struct { start: u32, end: u32 };
/// Where the terms that name text matched `text`, a thread's `field`,/// in order and apart, as many as `buf` holds: a word or phrase in any/// field, from:, to:, and cc: who is on it, subject: its subject. A term/// that leaves out what it matches marks nothing, nor does a name only/// a message's people hold.pub fn found(q: *const Query, field: Field, text: []const u8, buf: []Range) []Range { var n: usize = 0; for (q.terms[0..q.len]) |*t| { if (t.negated) continue; const needle = switch (t.what) { .text => |s| s, .from, .to, .cc => |s| if (field == .who) s else continue, .subject => |s| if (field == .subject) s else continue, else => continue, }; var at: usize = 0; while (n < buf.len) { const start = at + (ascii.findIgnoreCase(text[at..], needle) orelse break); var end = start + needle.len; // A word cut inside a character, as a full field cuts one, // marks the whole character. while (end < text.len and text[end] & 0xc0 == 0x80) end += 1; buf[n] = .{ .start = @intCast(start), .end = @intCast(end) }; n += 1; at = end; } } mem.sort(Range, buf[0..n], {}, startsFirst); // Overlapping or touching ranges are one. var merged: usize = 0; for (buf[0..n]) |r| { if (merged > 0 and r.start <= buf[merged - 1].end) { buf[merged - 1].end = @max(buf[merged - 1].end, r.end); } else { buf[merged] = r; merged += 1; } } return buf[0..merged];}
fn startsFirst(_: void, a: Range, b: Range) bool { return a.start < b.start;}
/// Whether the query asks for spam or trash, which views otherwise leave/// out.pub fn showsHidden(q: *const Query) bool { for (q.terms[0..q.len]) |*t| { if (t.negated) continue; switch (t.what) { .anywhere => return true, .label => |name| if (sameLabel(name, "spam") or sameLabel(name, "trash")) return true, else => {}, } } return false;}
/// The next term of `rest`, with its - and quotes, and `rest` past it./// Brackets, Gmail's grouping, are taken as spaces.fn nextToken(rest: *[]const u8) ?[]const u8 { const s = rest.*; var start: usize = 0; while (start < s.len and isGap(s[start])) start += 1; if (start == s.len) return null; var end = start; var quoted = false; while (end < s.len and (quoted or !isGap(s[end]))) : (end += 1) { if (s[end] == '"') quoted = !quoted; } rest.* = s[end..]; return s[start..end];}
fn isGap(c: u8) bool { return ascii.isWhitespace(c) or mem.findScalar(u8, "(){}", c) != null;}
/// The term a token asks, or null for one to ignore: OR, an operator/// this does not know, or one with nothing after it yet.fn term(token: []const u8, now: i64) ?Term { const negated = token.len > 1 and token[0] == '-'; const t = if (negated) token[1..] else token; if (mem.eql(u8, t, "OR") or mem.eql(u8, t, "AND")) return null; const colon = mem.findScalar(u8, t, ':'); const op_name = if (colon) |c| t[0..c] else ""; const is_operator = op_name.len > 0 and t[0] != '"' and for (op_name) |c| { if (!ascii.isAlphabetic(c) and c != '_') break false; } else true; if (!is_operator) { const text = unquote(t); return if (text.len == 0) null else .{ .negated = negated, .what = .{ .text = text }, .people = .{} }; } const value = unquote(t[colon.? + 1 ..]); if (value.len == 0) return null; const what: What = switch (operators.get(op_name) orelse return null) { .from => .{ .from = value }, .to => .{ .to = value }, .cc => .{ .cc = value }, .subject => .{ .subject = value }, .label => .{ .label = value }, .in => if (ascii.eqlIgnoreCase(value, "anywhere")) .anywhere else .{ .label = value }, .is => if (ascii.eqlIgnoreCase(value, "unread")) .{ .unread = true } else if (ascii.eqlIgnoreCase(value, "read")) .{ .unread = false } else if (ascii.eqlIgnoreCase(value, "starred")) .starred else return null, .before, .older => .{ .before = parseDate(value) orelse return null }, .after, .newer => .{ .after = parseDate(value) orelse return null }, .older_than => .{ .before = now - (parseAge(value) orelse return null) }, .newer_than => .{ .after = now - (parseAge(value) orelse return null) }, }; return .{ .negated = negated, .what = what, .people = .{} };}
fn unquote(s: []const u8) []const u8 { const start: usize = @intFromBool(s.len > 0 and s[0] == '"'); const end = if (s.len > start and s[s.len - 1] == '"') s.len - 1 else s.len; return s[start..end];}
/// A date as Gmail takes one: 2026/11/07 or 2026-11-07, at midnight UTC,/// or seconds since the epoch.fn parseDate(s: []const u8) ?i64 { if (std.fmt.parseInt(i64, s, 10)) |seconds| return seconds else |_| {} var parts = mem.tokenizeAny(u8, s, "/-"); var ymd: [3]i64 = undefined; for (&ymd) |*n| n.* = std.fmt.parseInt(i64, parts.next() orelse return null, 10) catch return null; if (parts.next() != null or ymd[1] < 1 or ymd[1] > 12 or ymd[2] < 1 or ymd[2] > 31) return null; return daysFromCivil(ymd[0], ymd[1], ymd[2]) * std.time.s_per_day;}
/// The days from 1970-01-01 to a date of the proleptic Gregorian/// calendar, by Howard Hinnant's days_from_civil; std counts only the/// other way.fn daysFromCivil(year: i64, month: i64, day: i64) i64 { const y = if (month <= 2) year - 1 else year; const era = @divFloor(y, 400); const yoe = y - era * 400; const doy = @divFloor(153 * (if (month > 2) month - 3 else month + 9) + 2, 5) + day - 1; const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; return era * 146097 + doe - 719468;}
/// An age as Gmail takes one, in seconds: 3d, 2m (of 30 days), or 1y/// (of 365).fn parseAge(s: []const u8) ?i64 { if (s.len < 2) return null; const n = std.fmt.parseInt(i64, s[0 .. s.len - 1], 10) catch return null; const days: i64 = switch (ascii.toLower(s[s.len - 1])) { 'd' => 1, 'm' => 30, 'y' => 365, else => return null, }; return n * days * std.time.s_per_day;}
fn matchesTerm(mail: *const Mailbox, t: *const Mailbox.Thread, term_: *const Term) bool { const who = &term_.people; return switch (term_.what) { .text => |s| has(t.participants, s) or has(t.subject, s) or has(t.snippet, s) or for (t.notes) |n| { if (isIn(who, n.from) or anyIn(who, n.people)) break true; } else false, // A thread with no notes has only Gmail's list of senders, and // "me" names the user, who sent what has the Sent label. .from => |s| if (t.notes.len == 0) has(t.participants, s) else for (t.notes) |n| { if (isIn(who, n.from)) break true; } else ascii.eqlIgnoreCase(s, "me") and t.labels.isSet(@backingInt(Mailbox.Label.sent)), .to => for (t.notes) |n| { if (anyIn(who, n.people[0..n.to])) break true; } else false, .cc => for (t.notes) |n| { if (anyIn(who, n.people[n.to..])) break true; } else false, .subject => |s| has(t.subject, s), .label => |name| { var it = t.labels.iterator(.{}); while (it.next()) |i| { const info = mail.label(@fromBackingInt(@intCast(i))); if (sameLabel(info.name, name) or sameLabel(info.id, name)) return true; } return false; }, .unread => |unread| t.unread == unread, .starred => t.labels.isSet(@backingInt(Mailbox.Label.starred)), .before => |time| if (t.notes.len == 0) t.date < time else for (t.notes) |n| { if (n.date < time) break true; } else false, .after => |time| if (t.notes.len == 0) t.date >= time else for (t.notes) |n| { if (n.date >= time) break true; } else false, .anywhere => true, };}
fn has(haystack: []const u8, needle: []const u8) bool { return ascii.findIgnoreCase(haystack, needle) != null;}
/// Whether person `p` is among `who`; one added since the query was/// made is not.fn isIn(who: *const std.bit_set.Dynamic, p: u32) bool { return p < who.capacity() and who.isSet(p);}
fn anyIn(who: *const std.bit_set.Dynamic, people: []const u32) bool { for (people) |p| if (isIn(who, p)) return true; return false;}
/// Whether two label names are one, ignoring case, with /, -, _, and/// space alike: work-projects is Work/Projects.fn sameLabel(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| { const gaps = "/-_ "; const same = ascii.toLower(x) == ascii.toLower(y) or (mem.findScalar(u8, gaps, x) != null and mem.findScalar(u8, gaps, y) != null); if (!same) return false; } return true;}
test "found marks each field's matches of the terms that name text there, merged, ignoring case" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); var q: Query = try .init(testing.allocator, "lis from:ravi subject:\"on b\" -maya to:me is:unread", 0, &mail); defer q.deinit(testing.allocator); var buf: [8]Range = undefined; // to:me marks "me" in who; "lis" marks every field; -maya marks nothing. try testing.expectEqualSlices(Range, &.{ .{ .start = 6, .end = 10 }, .{ .start = 12, .end = 14 } }, q.found( .who, "Maya, Ravi, me", &buf, )); // subject:"on b" and lis touch in "Lison brunch": one range. try testing.expectEqualSlices(Range, &.{.{ .start = 0, .end = 7 }}, q.found(.subject, "Lison brunch", &buf)); // from: marks only who. try testing.expectEqualSlices(Range, &.{.{ .start = 3, .end = 6 }}, q.found(.snippet, "In LIS, Ravi", &buf)); // A word cut inside a character marks all of it. var cut: Query = try .init(testing.allocator, "caf\xc3", 0, &mail); defer cut.deinit(testing.allocator); try testing.expectEqualSlices(Range, &.{.{ .start = 4, .end = 9 }}, cut.found(.subject, "The café", &buf));}
test "operators match who is on which message, labels by name, dates at their bounds, and - leaves out" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const gray: Mailbox.Rgb = .{ .r = 0, .g = 0, .b = 0 }; const work = try mail.addLabel(.{ .id = "Label_3", .name = "Work/Projects", .kind = .user, .color = gray }); const maya: Mailbox.Person = .{ .name = "Maya Okafor", .address = "Maya@Example.com" }; const ravi: Mailbox.Person = .{ .name = "", .address = "ravi@example.org" }; const me: Mailbox.Person = .{ .name = "Tim", .address = "tim@example.net" }; // 2024-01-02T00:00:00Z is 1704153600. const midnight = 1_704_153_600; const from_maya = try mail.addThread(.{ .id = "a", .participants = "Maya Okafor", .subject = "Lunch plans for Friday", .snippet = "", .date = midnight, .messages = 1, .labels = &.{ .inbox, work }, .unread = true, .notes = &.{.{ .date = midnight, .from = maya, .to = &.{me}, .cc = &.{ravi} }}, }); const maya_copied = try mail.addThread(.{ .id = "b", .participants = "me", .subject = "Plans", .snippet = "lunch at 12:30", .date = midnight - 1, .messages = 1, .labels = &.{ .sent, .starred }, .unread = false, .notes = &.{.{ .date = midnight - 1, .from = me, .to = &.{ravi}, .cc = &.{maya} }}, }); const trashed = try mail.addThread(.{ .id = "c", .participants = "Ravi", .subject = "Old", .snippet = "", .date = 100, .messages = 1, .labels = &.{.trash}, .unread = false, .notes = &.{}, }); const now = midnight + 3 * std.time.s_per_day; const cases = [_]struct { text: []const u8, want: []const Mailbox.Id }{ .{ .text = "from:maya", .want = &.{from_maya} }, .{ .text = "cc:MAYA", .want = &.{maya_copied} }, .{ .text = "to:ravi", .want = &.{maya_copied} }, .{ .text = "from:me", .want = &.{maya_copied} }, // A plain word matches the people of every message. .{ .text = "okafor", .want = &.{ from_maya, maya_copied } }, .{ .text = "maya -label:work-projects", .want = &.{maya_copied} }, .{ .text = "label:\"work projects\"", .want = &.{from_maya} }, .{ .text = "subject:\"lunch plans\"", .want = &.{from_maya} }, .{ .text = "\"lunch plans\"", .want = &.{from_maya} }, .{ .text = "lunch plans", .want = &.{ from_maya, maya_copied } }, .{ .text = "before:2024/01/02", .want = &.{maya_copied} }, .{ .text = "after:2024-01-02", .want = &.{from_maya} }, .{ .text = "older_than:3d", .want = &.{maya_copied} }, .{ .text = "newer_than:3d is:unread", .want = &.{from_maya} }, .{ .text = "is:read is:starred", .want = &.{maya_copied} }, // What it does not know it leaves to Gmail; a time is a word. .{ .text = "has:attachment 12:30", .want = &.{maya_copied} }, .{ .text = "from: OR", .want = &.{ from_maya, maya_copied } }, // Trash shows when asked for. .{ .text = "in:trash", .want = &.{trashed} }, .{ .text = "ravi in:anywhere", .want = &.{ from_maya, maya_copied, trashed } }, }; var ids: std.ArrayList(Mailbox.Id) = .empty; defer ids.deinit(testing.allocator); for (cases) |c| { var q: Query = try .init(testing.allocator, c.text, now, &mail); defer q.deinit(testing.allocator); try mail.query(.{ .query = &q }, &ids); testing.expectEqualSlices(Mailbox.Id, c.want, ids.items) catch |err| { print("query: {s}\n", .{c.text}); return err; }; }}